From b56702ad345201a1de46a5f8e94542a3a59ad3bd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Oct 2026 01:28:04 +0200 Subject: [PATCH] feat(durable): extensions and per-conversation agents (Package 21) - Registry of named extensions (tools, sections, hooks, wraps, tasks) with install/uninstall; defineExtension, defineTool, section, hook, wrapTool, wrapSection - pi.agent document (model, thinking level, extensions, tools filter, instructions, cwd), configure(), Conversation.agent()/configure(), agent option on root/createConversation/fork, task-owned conversations copy their owner's agent - Harness-wide HarnessSettings read at every use; env function built per use - Hooks and the agent resolved per task phase; tool calls resolved with the tools filter applied - ToolRegistration types execute() args from parameters - HarnessOptions.conversationCreated; Tx.submissionByRequest() and Tx.createSubmission() - FileSystem.id; edit/write serialize per file system id and canonical path - CodingTools extension (read, write, edit, bash) - Examples 06-31 on the new API, README and CHANGELOG --- packages/durable/CHANGELOG.md | 15 + packages/durable/README.md | 234 ++++++--- packages/durable/src/env/index.ts | 5 + packages/durable/src/env/node.ts | 2 + packages/durable/src/harness/agent.ts | 251 ++++++++++ packages/durable/src/harness/compaction.ts | 23 +- packages/durable/src/harness/config.ts | 54 -- packages/durable/src/harness/define.ts | 44 ++ packages/durable/src/harness/events.ts | 21 +- packages/durable/src/harness/generation.ts | 101 ++-- packages/durable/src/harness/harness.ts | 257 +++------- packages/durable/src/harness/inbox.ts | 13 +- packages/durable/src/harness/prompt.ts | 15 +- packages/durable/src/harness/registry.ts | 407 +++------------- packages/durable/src/harness/scheduler.ts | 78 +-- packages/durable/src/harness/submissions.ts | 22 +- packages/durable/src/harness/tool.ts | 17 +- packages/durable/src/harness/types.ts | 301 ++++++------ packages/durable/src/harness/view.ts | 11 +- packages/durable/src/index.ts | 22 +- packages/durable/src/session/transaction.ts | 4 +- packages/durable/src/tools/bash.ts | 9 +- packages/durable/src/tools/edit.ts | 24 +- .../durable/src/tools/file-mutation-queue.ts | 78 ++- packages/durable/src/tools/index.ts | 12 + packages/durable/src/tools/read.ts | 16 +- packages/durable/src/tools/write.ts | 9 +- packages/durable/src/types.ts | 26 +- packages/durable/test/chat-support.ts | 32 +- packages/durable/test/examples/06-harness.ts | 41 +- .../durable/test/examples/07-configuration.ts | 109 +++-- .../test/examples/08-harness-conversations.ts | 20 +- .../test/examples/10-registry-reload.ts | 53 +- .../test/examples/11-conversation-setup.ts | 47 -- .../test/examples/11-extension-state.ts | 86 ++++ packages/durable/test/examples/12-tasks.ts | 9 +- packages/durable/test/examples/13-recovery.ts | 4 +- packages/durable/test/examples/14-chat.ts | 17 +- .../durable/test/examples/15-system-prompt.ts | 90 ++-- .../durable/test/examples/16-real-model.ts | 25 +- .../durable/test/examples/17-coding-tools.ts | 55 ++- packages/durable/test/examples/18-print.ts | 17 +- packages/durable/test/examples/19-json.ts | 25 +- packages/durable/test/examples/20-inbox.ts | 12 +- .../durable/test/examples/21-late-join.ts | 36 +- .../test/examples/22-subagent-foreground.ts | 92 ++-- .../test/examples/23-subagent-background.ts | 38 +- .../durable/test/examples/24-child-tasks.ts | 12 +- .../durable/test/examples/25-compaction.ts | 15 +- .../durable/test/examples/26-coding-agent.ts | 81 +++ .../durable/test/examples/27-plan-mode.ts | 115 +++++ packages/durable/test/examples/28-reviewer.ts | 111 +++++ .../examples/29-sandbox-per-conversation.ts | 82 ++++ .../durable/test/examples/30-tool-override.ts | 102 ++++ .../test/examples/31-reload-and-restart.ts | 90 ++++ .../durable/test/harness-compaction.test.ts | 219 +++++---- .../test/harness-conversations.test.ts | 374 ++++++-------- packages/durable/test/harness-events.test.ts | 104 ++-- .../test/harness-generation-recovery.test.ts | 17 +- .../durable/test/harness-generation.test.ts | 133 +++-- packages/durable/test/harness-inbox.test.ts | 97 ++-- packages/durable/test/harness-inspect.test.ts | 8 +- .../durable/test/harness-live-deltas.test.ts | 113 +++-- .../durable/test/harness-ownership.test.ts | 263 +++++----- packages/durable/test/harness-prompt.test.ts | 66 +-- .../durable/test/harness-registry.test.ts | 461 ++++++++++-------- .../durable/test/harness-structured.test.ts | 141 +++--- packages/durable/test/harness-support.ts | 58 ++- .../test/harness-tasks-recovery.test.ts | 110 ++--- packages/durable/test/harness-tasks.test.ts | 157 +++++- .../test/harness-tools-recovery.test.ts | 81 ++- packages/durable/test/harness-tools.test.ts | 403 ++++++++++----- packages/durable/test/harness-view.test.ts | 10 +- packages/durable/test/task-support.ts | 7 +- packages/durable/test/tool-output-bench.ts | 85 ++-- packages/durable/test/tools.test.ts | 79 +++ scripts/check-entry-graphs.mjs | 2 +- 77 files changed, 3917 insertions(+), 2558 deletions(-) create mode 100644 packages/durable/src/harness/agent.ts delete mode 100644 packages/durable/src/harness/config.ts create mode 100644 packages/durable/src/harness/define.ts delete mode 100644 packages/durable/test/examples/11-conversation-setup.ts create mode 100644 packages/durable/test/examples/11-extension-state.ts create mode 100644 packages/durable/test/examples/26-coding-agent.ts create mode 100644 packages/durable/test/examples/27-plan-mode.ts create mode 100644 packages/durable/test/examples/28-reviewer.ts create mode 100644 packages/durable/test/examples/29-sandbox-per-conversation.ts create mode 100644 packages/durable/test/examples/30-tool-override.ts create mode 100644 packages/durable/test/examples/31-reload-and-restart.ts diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 8255cf72f..530105f7e 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -5,6 +5,21 @@ ### Breaking Changes - The portable SQLite facade in `@earendil-works/pi-durable/storage/sqlite` is asynchronous: `SqliteDatabase` extends the new `SqliteExecutor` (`exec`, `run`, `get`, `all` by SQL text), `prepare` and `SqliteStatement` are removed, `transaction` takes an async callback that receives a transaction handle, and `close()` returns a promise. Custom adapters must be rewritten ([#10232](https://github.com/earendil-works/pi/pull/10232) by [@christianklotz](https://github.com/christianklotz)). +- The registry holds named extensions: `registry.install(extension)` and `uninstall()` replace `tools`, `hooks`, `tasks`, `systemPrompt`, `conversations`, `batch()`, wrappers by key, and hook scopes. Build extensions with `defineExtension()`, `defineTool()`, `section()`, `hook()`, `wrapTool()`, and `wrapSection()`. +- The per-conversation `pi.conversation.config` document and the `Conversation` getters and setters are replaced by the rewindable `pi.agent` document (model, thinking level, extension and tool selection, `instructions`, `cwd`), `Conversation.agent()`, `Conversation.configure()`, `configure(tx, id, change)`, and the `agent` option of `root()`, `createConversation()`, and `fork()`. A task-owned conversation starts as a copy of its owner's conversation's agent. +- Stream options, retry and compaction policies, tool execution mode, and queue modes are Harness-wide `HarnessOptions.settings`, read at every use. +- `HarnessOptions.env` is a function that builds the environment per use from the conversation's ID and `cwd`. `TaskRuntime` gains `agent()`, `settings`, and `env()`; `ToolExecutionApi` gains `registry` and `agent()`, and `env` is built per call. +- `ToolRegistration` types `execute()` arguments from `parameters`, and `ToolExecutionApi` and `ToolExecutionResult` type details. `prepareArguments` takes `unknown` and returns the parameters' type. +- The event snapshot's `config` and the `config_changed` event are replaced by `agent` and `agent_changed`. +- `HarnessInspection.registry` is removed. +- `FileSystem` requires `id`: equal ids see the same files at the same paths. `edit` and `write` serialize changes to one file by `id` and canonical path instead of by environment object; `NodeExecutionEnv` uses `"node:local"`. + +### Added + +- `CodingTools` extension in `@earendil-works/pi-durable/tools` with `read`, `write`, `edit`, and `bash`. +- `AgentDoc`, `configure()`, `DEFAULT_RETRY_POLICY`, and `DEFAULT_COMPACTION_POLICY` exports. +- `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, after the built-in documents, so applications can create their own documents in every conversation. +- `Tx.submissionByRequest()` and `Tx.createSubmission()`; the latter writes a raw submission record without admission rules. ## [0.99.2] - 2026-09-30 diff --git a/packages/durable/README.md b/packages/durable/README.md index a78ca6420..04559c837 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -12,9 +12,13 @@ Built on [`@earendil-works/pi-ai`](../ai/README.md) for model access and `@earen - [Quick Start](#quick-start) - [Concepts](#concepts) - [Persist and Resume](#persist-and-resume) +- [Extensions](#extensions) - [Tools](#tools) - [System Prompt](#system-prompt) -- [Conversation Settings](#conversation-settings) +- [Per-Conversation Agent](#per-conversation-agent) +- [Settings](#settings) +- [Environment](#environment) +- [Reload](#reload) - [Watching a Conversation](#watching-a-conversation) - [Busy Conversations](#busy-conversations) - [Reset and Handoff](#reset-and-handoff) @@ -50,8 +54,7 @@ const models = createModels(); models.setProvider(openaiProvider()); // reads OPENAI_API_KEY const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context); -const root = await harness.root(context); -await root.setModel({ provider: "openai", modelId: "gpt-6-sol" }, context); +const root = await harness.root(context, { agent: { model: { provider: "openai", modelId: "gpt-6-sol" } } }); const submission = await root.submit({ type: "input", content: "What is the capital of France?" }, context); const settled = await submission.wait(context); @@ -65,7 +68,7 @@ await harness.close(context); What happened: - `Harness.open()` opens a Session over a storage backend. `MemoryStorage` keeps everything in memory. -- `root()` returns the root conversation, creating it on first use. A conversation is a transcript of immutable entries. +- `root()` returns the root conversation, creating it on first use with the given agent choices. A conversation is a transcript of immutable entries. - `submit()` durably admits your input and returns a `Submission`. A built-in generation task calls the model and appends the answer. - `wait()` resolves once the input is answered (`done`) or has failed (`unanswered`, with a reason). @@ -77,11 +80,13 @@ Every async call takes a Chord `Context`, which carries cancellation. `BACKGROUN - **Conversation**: a transcript. `root()` creates the root conversation on first use; you can create more and fork them. A `Conversation` handle holds no state; compare handles by `id`. - **Entry**: one immutable transcript record, such as a user message (`pi.user`), a model response (`pi.assistant`), a tool result (`pi.tool-result`), a system prompt change (`pi.system`), a reset (`pi.reset`), or your own kind. The model sees the entries from the newest reset onward. - **Commit**: an atomic write. `conversation.commit((tx) => ...)` can append entries, edit documents, and create tasks together; either all of it is stored or none of it. -- **Document**: typed JSON state stored next to the transcript and changed in commits. Built-in ones hold each conversation's settings (`pi.conversation.config`), the running generation and tools (`pi.live`), queued submissions (`pi.inbox`), and spend (`pi.usage`). +- **Document**: typed JSON state stored next to the transcript and changed in commits. Built-in ones hold each conversation's agent choices (`pi.agent`), the running generation and tools (`pi.live`), queued submissions (`pi.inbox`), and spend (`pi.usage`). - **Task**: a durable state machine that saves a checkpoint at every step, so a restarted process continues from the last one. Every task has an owner: its conversation, or another task. The Harness runs answers as built-in tasks: `pi.generation` calls the model and owns the `pi.tool` tasks of its tool calls, waits for them, and hands the run to the next generation. - **Submission**: something you hand to a conversation, either user input or an entry to write, which you can wait for. - **Turn and run**: a turn is one model response and its tool calls; a run is the turns from an input to its final answer. A conversation is busy while a run is going. -- **Registry**: your tools, system prompt sections, hooks, and tasks. It can change while the Harness runs; new work uses the new state. +- **Extension**: a named bundle of tools, system prompt sections, hooks, wrappers, and tasks. +- **Registry**: the extensions this process installed. It can change while the Harness runs; new work uses the new state. +- **Agent**: what a conversation runs with: model, thinking level, selected extensions, tools, instructions, and working directory. Stored per conversation as names in `pi.agent`, resolved against the registry at each use. One answered input, as entries and tasks: @@ -115,65 +120,140 @@ const again = await root.submit({ type: "input", content: "Hello", requestId: "g `harness.submission(id)` reacquires a submission by ID, for example to wait for it after a restart. -## Tools +## Extensions -`@earendil-works/pi-durable/tools` provides `read`, `bash`, `edit`, and `write`. They touch files and processes only through the execution environment passed as `env`: +Code the Harness runs, other than its built-in tasks, comes in named extensions installed in a registry your process owns: ```typescript -import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node"; -import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@earendil-works/pi-durable/tools"; +import { createRegistry, defineExtension, defineTool, hook, section, ToolTask } from "@earendil-works/pi-durable"; +import { CodingTools } from "@earendil-works/pi-durable/tools"; + +const Coding = defineExtension({ + name: "coding", + sections: [section("preamble", () => "You are a concise coding assistant.", { tag: false })], + hooks: [hook(ToolTask, { beforeTool: (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined) })], +}); const registry = createRegistry(); -for (const tool of [createReadTool(), createBashTool(), createEditTool(), createWriteTool()]) registry.tools.add(tool); -const env = new NodeExecutionEnv({ cwd: process.cwd() }); -const harness = await Harness.open(storage, { models, registry, env }, context); +registry.install(CodingTools); +registry.install(Coding); ``` -A new conversation activates every tool registered when it is created. Reading images is not supported yet. +An extension may bring `tools`, `sections`, `hooks`, `wraps` (decorators of a tool or section by name), and `tasks`. By default every conversation selects every installed extension, in install order. Nothing in the registry is stored; conversations store extension names. -Define your own tool with a TypeBox schema. `api.output()` streams running output, which becomes the result when `execute()` returns no `content`: +## Tools + +`@earendil-works/pi-durable/tools` provides `read`, `write`, `edit`, and `bash`, and the `CodingTools` extension with all four. They touch files and processes only through the call's environment (see [Environment](#environment)). Reading images is not supported yet. + +Define your own tool with a TypeBox schema. `defineTool()` types `args` from `parameters`, which the Harness validates before `execute()`. `api.output()` streams running output, which becomes the result when `execute()` returns no `content`: ```typescript import { Type } from "@earendil-works/pi-ai"; -registry.tools.add({ +const count = defineTool({ name: "count", description: "Count from 1 to n", parameters: Type.Object({ n: Type.Number() }), execute: async (args, api) => { - for (let i = 1; i <= (args as { n: number }).n; i++) api.output(`${i}\n`); + for (let i = 1; i <= args.n; i++) api.output(`${i}\n`); return {}; }, }); +registry.install(defineExtension({ name: "count", tools: [count] })); ``` Each call runs as its own durable task. Its intent is committed before `execute()` runs. If the process dies mid-call, the tool reruns on reopen only when it is declared `replay: "safe"`; otherwise the model gets an `interrupted` error result with the output committed so far. Throwing from `execute()` gives the model an error result. A result can also return `usage`, which is added to the conversation's [usage](#usage-and-cost). It can also return `control: { terminate: true }`: when every result of the round asks for it, the run ends without another model request. +A later extension's tool with the same name replaces an earlier one where both are selected, and `wrapTool()` decorates whichever tool won: + +```typescript +const Venv = defineExtension({ name: "venv", tools: [createBashTool({ commandPrefix: "source .venv/bin/activate" })] }); +const Timing = defineExtension({ + name: "timing", + wraps: [wrapTool(createBashTool(), (bash) => ({ ...bash, execute: (args, api, ctx) => timed(() => bash.execute(args, api, ctx)) }))], +}); +``` + ## System Prompt -The system prompt is built from named sections, rendered before each request: +The system prompt is built from the selected extensions' sections, rendered in order before each request. A section sees the resolved agent, the environment built for the request, and committed documents: ```typescript -registry.systemPrompt.section("preamble", () => "You are a concise coding assistant.", { tag: false }); -registry.systemPrompt.section("cwd", () => process.cwd()); // rendered as \n...\n +section("cwd", (input) => input.env?.cwd); // rendered as \n...\n; undefined omits it ``` -Sections and tool changes are stored as positional system entries in the transcript. Only what changed is sent again, which keeps provider prompt caches warm. A section that returns something different every time, such as the current time, defeats that. +A conversation's `instructions` render last, as the section `instructions`. Sections and tool changes are stored as positional system entries in the transcript. Only what changed is sent again, which keeps provider prompt caches warm. A section that returns something different every time, such as the current time, defeats that. -## Conversation Settings +## Per-Conversation Agent -Each conversation has durable settings, readable and writable through the handle: +Each conversation stores what it runs with in its `pi.agent` document. `configure()` changes it in one commit; unset fields follow the host: ```typescript -await root.setModel({ provider: "openai", modelId: "gpt-6-sol" }, context); -await root.setThinkingLevel("high", context); -await root.setActiveTools(["read", "bash"], context); -await root.setToolExecution("sequential", context); // default "parallel" -await root.setRetryPolicy({ enabled: true, maxRetries: 3, baseDelayMs: 2000 }, context); -await root.setStreamOptions({ timeoutMs: 120_000 }, context); +await root.configure( + { + model: { provider: "openai", modelId: "gpt-6-sol" }, + thinkingLevel: "high", + extensions: { remove: [Coding] }, // edits the host default; an array selects exactly these, in order + tools: [readTool, bashTool], // an array offers exactly these; { remove: [...] } drops some + instructions: "Only read; never edit files.", + cwd: "/work/repo", + }, + context, +); +await root.configure({ tools: null }, context); // null clears a field back to the host default +const agent = await root.agent(context); // resolved: model, extensions, tools, sections, cwd ``` -Changes apply when the next model request is prepared, not in the middle of one. +Extensions and tools are passed as objects and stored by name, so a stored name outlives its code: after an extension is uninstalled, conversations that select it just stop getting it until it is installed again. `createConversation()`, `fork()`, and `root()` take the same change as `agent`. A task-owned conversation, such as a subagent's, starts as a copy of its owner's conversation's agent. A fork starts with the agent its parent had at the fork entry. The model, prompt, and offered tools of a request are fixed when it is prepared; a change applies from the next request. Tool calls and hooks use the agent as their task phase resolves it, and the environment is built from the current `cwd` at each use, so a `cwd` or extension change can reach calls the model already made. + +## Settings + +Run policy shared by every conversation is passed as `settings`. It is read at every use and never stored, so getters make it live, for example backed by a settings file: + +```typescript +const harness = await Harness.open(storage, { + models, + registry, + settings: { + extensions: [CodingTools, Coding], // default selection; absent: every installed extension + stream: { timeoutMs: 120_000 }, + retry: { maxRetries: 3 }, + compaction: { reserveTokens: 16384 }, + toolExecution: "parallel", + get followUpMode() { + return userSettings.followUpMode; + }, + }, +}, context); +``` + +## Environment + +`env` builds the execution environment for each tool call, section rendering, and `runtime.env()`. It receives the conversation's ID, its agent `cwd`, and committed reads, so one function serves a directory per conversation or a container per conversation: + +```typescript +import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node"; + +const harness = await Harness.open(storage, { + models, + registry, + env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? process.cwd() }), +}, context); +``` + +A throw from `env` becomes the call's error result. Without an environment, the built-in tools fail with an error result. A fresh environment object per call is fine: `edit` and `write` serialize changes to one file by the environment's `id` and path. A custom `ExecutionEnv` sets `id` so that equal ids see the same files at the same paths, for example one id per container. + +## Reload + +Installing an extension with an installed name replaces it in place, in one step: + +```typescript +registry.install(await loadCodingExtension()); // same name "coding": replaces the installed one +``` + +`registry.uninstall(extension)` removes the installed extension with that name, whichever object it is. + +Work that already started keeps the code it took: a running tool call finishes under its old implementation, and each task phase resolves hooks and the agent once, from the registry at the phase's start. The next phase, request, or call uses the new code. After a restart, install the same extensions again; pending tasks of an extension's `tasks` resume once it is installed. ## Watching a Conversation @@ -184,7 +264,7 @@ const view = await root.viewState(context); view.subscribe((value) => { // value.entries: the active transcript // value.docs["pi.live"]: the running generation (streamed partial, retry, deferred) and tool calls (output, details) - // value.docs["pi.inbox"], value.docs["pi.usage"], value.docs["pi.conversation.config"] + // value.docs["pi.inbox"], value.docs["pi.usage"], value.docs["pi.agent"] render(value); }); // later: view.dispose(); @@ -220,7 +300,7 @@ await root.submit({ type: "write", entry: { kind: "app.note", data: "user opened - **Follow-ups** are placed when the run answers, and start the next run. - **Writes** append an entry without asking the model anything. - `await submission.abort(context)` withdraws a queued submission. -- `setSteeringMode("all")` and `setFollowUpMode("all")` place every queued item at once instead of one per turn. +- The [settings](#settings) `steeringMode: "all"` and `followUpMode: "all"` place every queued item at once instead of one per turn. If a run fails, queued items stay in the inbox until the next submission places them, oldest first. @@ -250,15 +330,17 @@ if (outcome.status === "completed" && outcome.result.submissionId !== undefined) The conversation keeps working while the summary is made. The summary is placed at once when the conversation is idle, otherwise at the next turn boundary. Esc (`abort()`) cancels a manual compaction. -Generation also compacts on its own, controlled per conversation: +Generation also compacts on its own, controlled by the [settings](#settings): ```typescript -await root.setCompaction({ - enabled: true, // automatic compaction; manual compact() always works - reserveTokens: 16384, // above contextWindow - reserveTokens, the next request waits for a compaction - keepRecentTokens: 20000, // roughly how much recent context stays verbatim - backgroundTokens: 32768, // this far below that, a compaction starts in the background; 0 disables it -}, context); +settings: { + compaction: { + enabled: true, // automatic compaction; manual compact() always works + reserveTokens: 16384, // above contextWindow - reserveTokens, the next request waits for a compaction + keepRecentTokens: 20000, // roughly how much recent context stays verbatim + backgroundTokens: 32768, // this far below that, a compaction starts in the background; 0 disables it + }, +} ``` When a provider rejects a request because the context is too long, generation compacts and retries once. A summary that would cut before the start of the current context settles as `stale` when it is placed, so when several are in flight, the furthest cut stays in effect. Summarization spend counts in `pi.usage`. A `beforeCompact` hook on `CompactionTask` can decline or supply its own summary. @@ -273,7 +355,7 @@ For consumers that want coding-agent style events (`message_start`, `message_upd import { watchEvents } from "@earendil-works/pi-durable"; const stream = await watchEvents(harness, root.id, context); -initialize(stream.snapshot); // entries, run, in-flight generation, tools, compactions, inbox, config, usage +initialize(stream.snapshot); // entries, run, in-flight generation, tools, compactions, inbox, agent, usage stream.start(async (events) => { for (const event of events) console.log(JSON.stringify(event)); }); @@ -283,23 +365,24 @@ Events are derived from commits, one batch per commit, and apply on top of the s ## Hooks -Hooks let extensions observe or adjust the built-in tasks: +Hooks let extensions observe or adjust the built-in tasks, in the conversations that select them: ```typescript -import { GenerationTask, ToolTask } from "@earendil-works/pi-durable"; +import { GenerationTask, hook, ToolTask } from "@earendil-works/pi-durable"; -registry.hooks.add(ToolTask, { - beforeTool: (call) => (call.name === "bash" ? { block: "bash is disabled here" } : undefined), -}); -registry.hooks.add(GenerationTask, { - onYield: (answer) => (needsMoreWork(answer) ? { continue: "Keep going." } : undefined), +const Guard = defineExtension({ + name: "guard", + hooks: [ + hook(ToolTask, { beforeTool: (call) => (call.name === "bash" ? { block: "bash is disabled here" } : undefined) }), + hook(GenerationTask, { onYield: (answer) => (needsMoreWork(answer) ? { continue: "Keep going." } : undefined) }), + ], }); ``` - **Generation:** `beforeRequest` (replace the messages of one request), `afterResponse`, `onYield` (continue the run with another user message), and `afterTools` (runs once a round's tools are done). - **Tools:** `beforeTool` (block or rewrite arguments) and `afterTool` (replace the result). -Pass `{ scope: { conversationId } }` to limit a hook to one conversation. +To limit a hook to some conversations, select its extension only there, for example with `configure({ extensions: { add: [Guard] } })`. ## More Conversations and Forks @@ -308,7 +391,7 @@ const other = await harness.createConversation({ ownership: { kind: "ownerless" const fork = await root.fork(entryId, { ownership: { kind: "ownerless" } }, context); ``` -A fork sees its parent's entries up to `entryId` and continues independently. It keeps the parent's settings as of that entry. +A fork sees its parent's entries up to `entryId` and continues independently. It keeps the parent's agent as of that entry. Both take `agent` and `init`, applied in the creating commit. ## Abort and Subagents @@ -317,25 +400,32 @@ A fork sees its parent's entries up to `entryId` and continues independently. It A conversation can be **owned** by a task. A subagent tool creates its child inside `api.commit()` with `ownership: { kind: "task", taskId: api.taskId }`, then drives it through `api.conversation(id)`: ```typescript -registry.tools.add({ +const Subagent: Extension = defineExtension({ name: "subagent", - description: "Delegate a self-contained task to a subagent and get its answer back.", - parameters: Type.Object({ task: Type.String() }), - replay: "safe", // a rerun after a crash finds the same child and submission - execute: async (args, api, context) => { - const child = await api.commit(async (tx) => { - // The ownership index remembers the child, so a rerun reuses it. - const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0]; - if (existing !== undefined) return existing.id; - const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - (await tx.doc(ConversationConfig, created.id)).model = { provider: "openai", modelId: "gpt-6-sol" }; - return created.id; - }, context); - await api.details({ conversationId: child }, context); // lets a UI attach to the child - const request = { type: "input", content: (args as { task: string }).task, requestId: `subagent:${api.taskId}` } as const; - const settled = await (await (await api.conversation(child, context))!.submit(request, context)).wait(context); - return { content: [{ type: "text", text: settled.status }] }; - }, + tools: [ + defineTool({ + name: "subagent", + description: "Delegate a self-contained task to a subagent and get its answer back.", + parameters: Type.Object({ task: Type.String() }), + replay: "safe", // a rerun after a crash finds the same child and submission + execute: async (args, api, context) => { + const child = await api.commit(async (tx) => { + // The ownership index remembers the child, so a rerun reuses it. + 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, extensions, tools, cwd. + const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); + // A cheaper model, and no subagents of its own. + await configure(tx, created.id, { model: haiku, extensions: { remove: [Subagent] } }); + return created.id; + }, context); + await api.details({ conversationId: child }, context); // lets a UI attach to the child + const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` } as const; + const settled = await (await (await api.conversation(child, context))!.submit(request, context)).wait(context); + return { content: [{ type: "text", text: settled.status }] }; + }, + }), + ], }); ``` @@ -399,7 +489,7 @@ await root.commit(async (tx) => { console.log(await harness.snapshot(Todos, root.id, context)); ``` -`harness.watchDoc()` and `harness.documentState()` observe one document like the view above. `registry.conversations.setup()` creates your documents in every new conversation, and a tool reads them through its `api`. +`harness.watchDoc()` and `harness.documentState()` observe one document like the view above. `HarnessOptions.conversationCreated(tx, conversation)` runs in every commit that creates or forks a conversation, including a tool's raw `tx.createConversation()`, so every conversation gets your documents; `init` in `createConversation()`, `fork()`, and `root()` writes per-call data in the same commit. An extension's tools, sections, and hooks read their own documents through `api` or `input.read`, and treat an absent one as its default ([`11-extension-state.ts`](test/examples/11-extension-state.ts)). ## Usage and Cost @@ -467,7 +557,13 @@ node --conditions=source --experimental-strip-types test/examples/14-chat.ts | [23-subagent-background](test/examples/23-subagent-background.ts) | Persistent subagents: spawn, steer, stop, list, answers reported back, restart-safe | | [24-child-tasks](test/examples/24-child-tasks.ts) | A checkout that owns and waits for four payments: failFast, abort, restart | | [25-compaction](test/examples/25-compaction.ts) | A long chat compacted in the background, manually, and after a context overflow | -| [00](test/examples/00-conversation.ts)–[13](test/examples/13-recovery.ts) | The layers underneath: sessions, documents, forks, watches, tasks, recovery | +| [26-coding-agent](test/examples/26-coding-agent.ts) | CodingTools, live settings from a settings object, an environment that follows the conversation's directory | +| [27-plan-mode](test/examples/27-plan-mode.ts) | A read-only plan mode as an extension with its own document, switched with `configure()` | +| [28-reviewer](test/examples/28-reviewer.ts) | A reviewer conversation with its own model, extensions, tools, directory, and review loop | +| [29-sandbox-per-conversation](test/examples/29-sandbox-per-conversation.ts) | An environment per conversation, looked up from an app document | +| [30-tool-override](test/examples/30-tool-override.ts) | A same-name bash for some conversations, and a wrapper that times whichever bash won | +| [31-reload-and-restart](test/examples/31-reload-and-restart.ts) | Reloading an extension mid-call, and stored choices surviving a restart | +| [00](test/examples/00-conversation.ts)–[13](test/examples/13-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/src/env/index.ts b/packages/durable/src/env/index.ts index 63a520f5d..bb0177854 100644 --- a/packages/durable/src/env/index.ts +++ b/packages/durable/src/env/index.ts @@ -94,6 +94,11 @@ export interface TextLineReader { /** Portable filesystem capability. Operations return failures rather than throwing. */ export interface FileSystem { + /** + * The file namespace: equal ids see the same files at the same paths, whatever their `cwd`. Every local Node + * environment shares one id; each container or remote host has its own. + */ + readonly id: string; cwd: string; absolutePath(path: string, context: Context): Promise>; joinPath(parts: string[], context: Context): Promise>; diff --git a/packages/durable/src/env/node.ts b/packages/durable/src/env/node.ts index 4d25aac24..01b791b37 100644 --- a/packages/durable/src/env/node.ts +++ b/packages/durable/src/env/node.ts @@ -435,6 +435,8 @@ class NodeTextLineReader implements TextLineReader { } export class NodeExecutionEnv implements ExecutionEnv { + /** Every local environment sees the same files. */ + readonly id: string = "node:local"; cwd: string; private shellPath?: string; private shellEnv?: NodeJS.ProcessEnv; diff --git a/packages/durable/src/harness/agent.ts b/packages/durable/src/harness/agent.ts new file mode 100644 index 000000000..9a6b8bcb7 --- /dev/null +++ b/packages/durable/src/harness/agent.ts @@ -0,0 +1,251 @@ +import { copyJson, type Draft } from "@earendil-works/chord"; +import { defineDoc } from "../documents.ts"; +import type { ConversationId, ConversationRecord, Tx } from "../types.ts"; +import type { + Agent, + AgentChange, + AgentState, + CompactionPolicy, + ConversationRetryPolicy, + Extension, + HarnessSettings, + PromptSection, + RegistrySnapshot, + Settings, + ToolRegistration, +} from "./types.ts"; + +export const DEFAULT_RETRY_POLICY: ConversationRetryPolicy = { + enabled: true, + maxRetries: 3, + baseDelayMs: 2000, + maxAgentDelayMs: 60000, +}; + +export const DEFAULT_COMPACTION_POLICY: CompactionPolicy = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, + backgroundTokens: 32768, +}; + +/** The reserved section key of the agent's `instructions`. */ +export const INSTRUCTIONS_KEY = "instructions"; + +/** Built-in agent document; rewindable so forks start from the agent at their fork entry. */ +export const AgentDoc = defineDoc({ + kind: "pi.agent", + version: 1, + scope: "conversation", + history: "rewindable", + fork: "asOf", + initial: () => ({}), + checkpointWhen: () => true, +}); + +/** Resolve the host settings: every field over its built-in default, object fields merged. */ +export function resolveSettings(settings: HarnessSettings | undefined): Settings { + const extensions = settings?.extensions; + return { + ...(extensions === undefined ? {} : { extensions }), + stream: { ...settings?.stream }, + retry: { ...DEFAULT_RETRY_POLICY, ...settings?.retry }, + compaction: { ...DEFAULT_COMPACTION_POLICY, ...settings?.compaction }, + toolExecution: settings?.toolExecution ?? "parallel", + steeringMode: settings?.steeringMode ?? "one-at-a-time", + followUpMode: settings?.followUpMode ?? "one-at-a-time", + }; +} + +/** Apply one change to `pi.agent`: a given field replaces the stored one, `null` clears it, `undefined` changes nothing. */ +export async function configure(tx: Tx, conversationId: ConversationId, change: AgentChange): Promise { + const state = await tx.doc(AgentDoc, conversationId); + applyChange(state, change); +} + +/** + * `addTools` of a tool round: an array gets each name it lacks appended, `{ remove }` loses the names, and unset tools + * already offer every tool, so nothing is written. + */ +export async function addTools(tx: Tx, conversationId: ConversationId, added: readonly string[]): Promise { + const state = await tx.doc(AgentDoc, conversationId); + const tools = state.tools; + if (tools === undefined) return; + if (Array.isArray(tools)) { + for (const name of added) if (!tools.includes(name)) tools.push(name); + } else { + const remove = (tools as { remove: string[] }).remove; + if (remove.some((name) => added.includes(name))) { + state.tools = { remove: remove.filter((name) => !added.includes(name)) }; + } + } +} + +function applyChange(state: Draft, change: AgentChange): void { + const set = (key: K, value: AgentState[K] | null | undefined) => { + if (value === undefined) return; + if (value === null) delete state[key]; + else state[key] = value as Draft[K]; + }; + set("model", change.model === undefined || change.model === null ? change.model : { ...change.model }); + set("thinkingLevel", change.thinkingLevel); + const extensions = change.extensions; + set( + "extensions", + extensions === undefined || extensions === null + ? extensions + : isList(extensions) + ? names(extensions) + : { + ...(extensions.add === undefined ? {} : { add: names(extensions.add) }), + ...(extensions.remove === undefined ? {} : { remove: names(extensions.remove) }), + }, + ); + const tools = change.tools; + set( + "tools", + tools === undefined || tools === null ? tools : isList(tools) ? names(tools) : { remove: names(tools.remove) }, + ); + set("instructions", change.instructions); + set("cwd", change.cwd); +} + +function isList(value: readonly T[] | object): value is readonly T[] { + return Array.isArray(value); +} + +function names(items: readonly { readonly name: string }[]): string[] { + return items.map((item) => item.name); +} + +/** + * Built-in part of every Harness commit that creates or forks a conversation, for `pi.agent`: a fork keeps its `asOf` + * copy; a new task-owned conversation copies the stored agent of its owner task's conversation; a new ownerless one + * starts empty. + */ +export async function createAgent(tx: Tx, conversation: ConversationRecord): Promise { + if (conversation.parent !== undefined) return; + const agent = await tx.doc(AgentDoc, conversation.id); + if (conversation.owner === undefined) return; + const owner = await tx.doc(AgentDoc, conversation.owner.conversationId); + Object.assign(agent, copyJson(owner) as AgentState); +} + +/** Handlers of the selected extensions' hooks for a task name, in extension order. */ +export function agentHooks(agent: Agent, taskName: string): object[] { + const handlers: object[] = []; + for (const extension of agent.extensions) { + for (const hook of extension.hooks ?? []) if (hook.task === taskName) handlers.push(hook.handlers); + } + return handlers; +} + +/** + * Resolve an agent from its stored state (absent: every field unset), a registry snapshot, and resolved settings. A + * wrapper that throws or renames drops its target and is reported; a wrapper without a target does nothing. + */ +export function resolveAgent( + state: Readonly | undefined, + snapshot: RegistrySnapshot, + settings: Settings, + report: (error: unknown) => void, +): Agent { + const extensions = selectExtensions(state?.extensions, snapshot, settings); + + const composed = new Map(); + for (const extension of extensions) for (const tool of extension.tools ?? []) composed.set(tool.name, tool); + const sections = new Map>(); + for (const extension of extensions) { + for (const section of extension.sections ?? []) sections.set(section.key, section); + } + for (const extension of extensions) { + for (const wrap of extension.wraps ?? []) { + if ("tool" in wrap) + applyWrap( + composed, + wrap.tool, + (tool) => wrap.wrap(tool), + (tool) => tool.name, + report, + ); + else + applyWrap( + sections, + wrap.section, + (section) => wrap.wrap(section), + (section) => section.key, + report, + ); + } + } + + const filter = state?.tools; + let tools: Tool[]; + if (filter === undefined) tools = [...composed.values()]; + else if (Array.isArray(filter)) { + tools = []; + for (const name of new Set(filter)) { + const tool = composed.get(name); + if (tool !== undefined) tools.push(tool); + } + } else { + const removed = new Set((filter as { remove: string[] }).remove); + tools = [...composed.values()].filter((tool) => !removed.has(tool.name)); + } + + const instructions = state?.instructions; + const agentSections = [...sections.values()]; + if (instructions !== undefined) agentSections.push({ key: INSTRUCTIONS_KEY, render: () => instructions }); + + const agent: Agent = { + ...(state?.model === undefined ? {} : { model: state.model }), + thinkingLevel: state?.thinkingLevel ?? "off", + extensions, + tools, + sections: agentSections, + ...(instructions === undefined ? {} : { instructions }), + ...(state?.cwd === undefined ? {} : { cwd: state.cwd }), + }; + return agent; +} + +/** Selected installed extensions: the stored array, or the default selection edited by `{ add, remove }`. */ +function selectExtensions( + stored: AgentState["extensions"], + snapshot: RegistrySnapshot, + settings: Settings, +): Extension[] { + let selected: string[]; + if (Array.isArray(stored)) selected = stored; + else { + const base = settings.extensions?.map((extension) => extension.name) ?? snapshot.installed().map((e) => e.name); + const edit = stored as { add?: string[]; remove?: string[] } | undefined; + const removed = new Set(edit?.remove ?? []); + selected = [...base, ...(edit?.add ?? [])].filter((name) => !removed.has(name)); + } + const extensions: Extension[] = []; + for (const name of new Set(selected)) { + const extension = snapshot.extension(name); + if (extension !== undefined) extensions.push(extension); + } + return extensions; +} + +function applyWrap( + items: Map, + target: string, + wrap: (item: T) => T, + nameOf: (item: T) => string, + report: (error: unknown) => void, +): void { + const item = items.get(target); + if (item === undefined) return; + try { + const wrapped = wrap(item); + if (nameOf(wrapped) !== target) throw new Error(`Wrapper renamed ${target} to ${nameOf(wrapped)}`); + items.set(target, wrapped); + } catch (error) { + items.delete(target); + report(error); + } +} diff --git a/packages/durable/src/harness/compaction.ts b/packages/durable/src/harness/compaction.ts index f16a4c11e..5f36642cf 100644 --- a/packages/durable/src/harness/compaction.ts +++ b/packages/durable/src/harness/compaction.ts @@ -3,7 +3,6 @@ import type { AssistantMessage, Message, ModelThinkingLevel, SimpleStreamOptions import { calculateContextTokens, estimateMessageTokens } from "@earendil-works/pi-ai/utils/estimate"; import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/utils/retry"; import { CompactionEntry } from "../entries.ts"; -import type { Transaction } from "../session/transaction.ts"; import { defineTask } from "../tasks.ts"; import type { ConversationId, @@ -15,7 +14,6 @@ import type { TaskRuntime, Tx, } from "../types.ts"; -import { ConversationConfig, DEFAULT_COMPACTION_POLICY, DEFAULT_RETRY_POLICY } from "./config.ts"; import { orderToolResults } from "./context.ts"; import { addCompactionStatus, compactionStatus, LiveDoc, type LiveState, removeCompactionStatus } from "./live.ts"; import { admitSubmission } from "./submissions.ts"; @@ -108,13 +106,12 @@ export const CompactionTask = defineTask { const { conversationId } = runtime; - const config = - (await runtime.snapshot(ConversationConfig, conversationId, context)) ?? - ConversationConfig.definition.initial(); - const ref = config.model; + const agent = await runtime.agent(context); + const settings = runtime.settings; + const ref = agent.model; const model = ref === undefined ? undefined : runtime.models.getModel(ref.provider, ref.modelId); if (ref === undefined || model === undefined) return failNoModel(runtime, ref, context); - const policy = config.compaction ?? DEFAULT_COMPACTION_POLICY; + const policy = settings.compaction; const view = await runtime.context(conversationId, context); const cut = selectCut(view, policy.keepRecentTokens); if (cut === undefined) return complete(runtime, context); @@ -136,8 +133,8 @@ export const CompactionTask = defineTask 0 ? model.maxTokens : Number.POSITIVE_INFINITY, @@ -176,9 +173,7 @@ export const CompactionTask = defineTask({ - kind: "pi.conversation.config", - version: 1, - scope: "conversation", - history: "rewindable", - fork: "asOf", - initial: () => ({ thinkingLevel: "off", activeTools: [] }), - checkpointWhen: () => true, -}); diff --git a/packages/durable/src/harness/define.ts b/packages/durable/src/harness/define.ts new file mode 100644 index 000000000..b9bbd913f --- /dev/null +++ b/packages/durable/src/harness/define.ts @@ -0,0 +1,44 @@ +import type { JsonValue } from "@earendil-works/chord"; +import type { TSchema } from "@earendil-works/pi-ai"; +import type { AnyTask, Extension, HookRegistration, HooksOf, PromptSection, ToolRegistration, Wrap } from "./types.ts"; + +/** Identity function that types an extension. */ +export function defineExtension( + extension: Extension, +): Extension { + return extension; +} + +/** Identity function that types a tool: `args` from `parameters`, details from what it reports. */ +export function defineTool( + tool: ToolRegistration, +): ToolRegistration { + return tool; +} + +/** A prompt section; tagged unless `tag` is false. */ +export function section( + key: string, + render: PromptSection["render"], + options?: { readonly tag?: boolean }, +): PromptSection { + return options?.tag === undefined ? { key, render } : { key, render, tag: options.tag }; +} + +/** Hook handlers for tasks with `task`'s name. */ +export function hook(task: K, handlers: Partial>): HookRegistration { + return { task: task.definition.name, handlers }; +} + +/** Wrap the tool named like `tool` wherever the wrapping extension is selected. */ +export function wrapTool(tool: Tool, wrapper: (tool: Tool) => Tool): Wrap { + return { tool: tool.name, wrap: wrapper }; +} + +/** Wrap the section `key` wherever the wrapping extension is selected. */ +export function wrapSection( + key: string, + wrapper: (section: PromptSection) => PromptSection, +): Wrap { + return { section: key, wrap: wrapper }; +} diff --git a/packages/durable/src/harness/events.ts b/packages/durable/src/harness/events.ts index dd3de87ce..dc14df949 100644 --- a/packages/durable/src/harness/events.ts +++ b/packages/durable/src/harness/events.ts @@ -13,10 +13,10 @@ import type { TaskId, WatchEnd, } from "../types.ts"; -import { ConversationConfig, type ConversationConfigState } from "./config.ts"; +import { AgentDoc } from "./agent.ts"; import type { InboxItem, InboxState } from "./inbox.ts"; import type { CompactionStatus, LiveState, ToolSlot } from "./live.ts"; -import type { CompactionReason, Harness, ToolDiagnostic } from "./types.ts"; +import type { AgentState, CompactionReason, Harness, ToolDiagnostic } from "./types.ts"; import { UsageDoc, type UsageState } from "./usage.ts"; import { scanAll } from "./util.ts"; import { type ConversationView, conversationViews } from "./view.ts"; @@ -47,7 +47,8 @@ export type SnapshotEvent = { /** `pi.live.compactions`: live compactions with their attempt and retry backoff. */ compactions: readonly CompactionStatus[]; inbox: readonly QueuedItem[]; - config: ConversationConfigState; + /** `pi.agent`; `{}` when absent. */ + agent: AgentState; usage: UsageState; }; @@ -80,7 +81,7 @@ export type AgentEvent = | { type: "auto_retry_end"; attempt: number } | { type: "deferred_poll"; pollAt: number } | { type: "entry_appended"; entry: EntryRecord } - | { type: "config_changed"; config: ConversationConfigState } + | { type: "agent_changed"; agent: AgentState } | { type: "usage_changed"; usage: UsageState } | { type: "task_failed"; taskId: TaskId; kind: string; message: string } | { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean } @@ -100,7 +101,7 @@ export interface AgentEventStream { type Parts = { live: LiveState; inbox: InboxState | undefined; - config: ConversationConfigState | undefined; + agent: AgentState | undefined; usage: UsageState | undefined; }; @@ -108,13 +109,13 @@ function parts(view: ConversationView): Parts { return { live: (view.docs["pi.live"] ?? {}) as LiveState, inbox: view.docs["pi.inbox"] as InboxState | undefined, - config: view.docs["pi.conversation.config"] as ConversationConfigState | undefined, + agent: view.docs["pi.agent"] as AgentState | undefined, usage: view.docs["pi.usage"] as UsageState | undefined, }; } function snapshotOf(view: ConversationView): SnapshotEvent { - const { live, inbox, config, usage } = parts(view); + const { live, inbox, agent, usage } = parts(view); return { type: "snapshot", entries: view.entries, @@ -123,7 +124,7 @@ function snapshotOf(view: ConversationView): SnapshotEvent { tools: live.tools ?? [], compactions: live.compactions ?? [], inbox: queued(inbox), - config: config ?? ConversationConfig.definition.initial(), + agent: agent ?? AgentDoc.definition.initial(), usage: usage ?? UsageDoc.definition.initial(), }; } @@ -328,8 +329,8 @@ function translate( for (const record of submissions) events.push({ type: "submission", record }); if (now.inbox !== was.inbox) events.push({ type: "inbox_update", items: queued(now.inbox) }); // A retired document reads as its initial value, as in a snapshot. - if (now.config !== was.config) { - events.push({ type: "config_changed", config: now.config ?? ConversationConfig.definition.initial() }); + if (now.agent !== was.agent) { + events.push({ type: "agent_changed", agent: now.agent ?? AgentDoc.definition.initial() }); } if (now.usage !== was.usage) events.push({ type: "usage_changed", usage: now.usage ?? UsageDoc.definition.initial() }); diff --git a/packages/durable/src/harness/generation.ts b/packages/durable/src/harness/generation.ts index 4e81889d9..7cc8a9c8b 100644 --- a/packages/durable/src/harness/generation.ts +++ b/packages/durable/src/harness/generation.ts @@ -13,6 +13,7 @@ import { isContextOverflow } from "@earendil-works/pi-ai/utils/overflow"; import { isRetryableAssistantError, retryDelayMs } from "@earendil-works/pi-ai/utils/retry"; import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; import { AssistantEntry, ResetEntry, SystemEntry, UserEntry } from "../entries.ts"; +import type { ExecutionEnv } from "../env/index.ts"; import { defineTask } from "../tasks.ts"; import type { ConversationId, @@ -24,12 +25,12 @@ import type { Tx, TypedEntry, } from "../types.ts"; +import { addTools } from "./agent.ts"; import { createCompaction, estimateContext, selectCut } from "./compaction.ts"; -import { ConversationConfig, DEFAULT_COMPACTION_POLICY, DEFAULT_RETRY_POLICY } from "./config.ts"; import { applyBoundary, prepareBoundary } from "./inbox.ts"; import { assignJson } from "./json.ts"; import { endRun, LiveDoc, type LiveState, type ToolSlot } from "./live.ts"; -import { desiredTools, planSystemEntries, renderSections, replaySections } from "./prompt.ts"; +import { planSystemEntries, renderSections, replaySections } from "./prompt.ts"; import { appendToolResult, harnessError, ToolTask, type ToolTaskResult } from "./tool.ts"; import type { CompactionPolicy, @@ -40,8 +41,6 @@ import type { ModelRef, PromptInput, ToolControl, - ToolExecutionMode, - ToolRegistration, UserInput, } from "./types.ts"; import { recordUsage } from "./usage.ts"; @@ -63,9 +62,8 @@ export type GenerationCheckpoint = compacted?: TaskId; model: ModelRef; thinkingLevel: ModelThinkingLevel; - /** Configured request options when preparation committed; a resend after recovery uses them unchanged. */ + /** The settings' request options when preparation committed; a resend after recovery uses them unchanged. */ streamOptions: ConversationStreamOptions; - toolExecution: ToolExecutionMode; /** Newest entry included in the request. */ cutoff: EntryId; } @@ -75,7 +73,6 @@ export type GenerationCheckpoint = attempt: number; compacted?: TaskId; model: ModelRef; - toolExecution: ToolExecutionMode; cutoff: EntryId; handle: DeferredHandle; pollAt: number; @@ -101,7 +98,6 @@ type Request = { readonly attempt: number; readonly compacted: TaskId | undefined; readonly model: ModelRef; - readonly toolExecution: ToolExecutionMode; readonly cutoff: EntryId; /** Committed model context through `cutoff`, when the phase already derived it. */ readonly messages?: readonly Message[]; @@ -123,16 +119,14 @@ export const GenerationTask = defineTask { - const { conversationId, registry } = runtime; - for (const failure of registry.failures()) runtime.report(failure.error); - const config = - (await runtime.snapshot(ConversationConfig, conversationId, context)) ?? - ConversationConfig.definition.initial(); - const { model, thinkingLevel, streamOptions } = config; + const { conversationId } = runtime; + const agent = await runtime.agent(context); + const settings = runtime.settings; + const { model, thinkingLevel } = agent; const resolved = model === undefined ? undefined : runtime.models.getModel(model.provider, model.modelId); if (model === undefined || resolved === undefined) return failNoModel(runtime, model, context); const { attempt, compacted, overflow } = task.state.checkpoint; @@ -144,26 +138,20 @@ export const GenerationTask = defineTask registry.tool(name)); - const input: PromptInput = { - conversationId, - tools, - shown: Object.fromEntries(shown), - model, - thinkingLevel, - read: runtime, - }; const report = (error: unknown) => runtime.report(error); - const desired = await renderSections(registry.sections(), input, shown, report, context); - const entries = planSystemEntries(view, desired, tools, runtime.now()); + let env: ExecutionEnv | undefined; + try { + env = await runtime.env(context); + } catch (error) { + if (context.abortSignal?.aborted) throw error; + report(error); + } + const input: PromptInput = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime }; + const desired = await renderSections(agent.sections, input, shown, report, context); + const entries = planSystemEntries(view, desired, agent.tools, runtime.now()); const threshold = compacted === undefined - ? thresholdCompaction( - view, - entries, - resolved.contextWindow, - config.compaction ?? DEFAULT_COMPACTION_POLICY, - ) + ? thresholdCompaction(view, entries, resolved.contextWindow, settings.compaction) : undefined; if (threshold === "blocking") { // Compact first and prepare again; the transcript is unchanged until the compaction appends. @@ -187,23 +175,14 @@ export const GenerationTask = defineTask { - const { - attempt, - compacted, - model: ref, - thinkingLevel, - streamOptions, - toolExecution, - cutoff, - } = task.state.checkpoint; + const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint; const conversationId = runtime.conversationId; await runtime.commit(async (tx) => { const live = await tx.doc(LiveDoc, conversationId); @@ -225,7 +204,7 @@ export const GenerationTask = defineTask { @@ -242,12 +221,12 @@ export const GenerationTask = defineTask { - const { attempt, compacted, model: ref, toolExecution, cutoff, handle, pollAt } = task.state.checkpoint; + const { attempt, compacted, model: ref, cutoff, handle, pollAt } = task.state.checkpoint; const model = runtime.models.getModel(ref.provider, ref.modelId); if (model === undefined) return failNoModel(runtime, ref, context); await runtime.sleep(pollAt, context); const message = await runtime.models.fetchDeferred(model, handle, { signal: runtime.signal }); - const request = { attempt, compacted, model: ref, toolExecution, cutoff, pollAt }; + const request = { attempt, compacted, model: ref, cutoff, pollAt }; await classify(runtime, request, message, context); }, tools: async (task, runtime, context) => { @@ -443,7 +422,7 @@ async function classify( // An abort mark or close: the abort invocation or the reopened run handles the committed state. runtime.signal.throwIfAborted(); const conversationId = runtime.conversationId; - const { attempt, compacted, model: ref, toolExecution, cutoff } = request; + const { attempt, compacted, model: ref, cutoff } = request; if (message.stopReason === "deferred" && message.deferred !== undefined) { const handle = message.deferred; const pollAt = Math.max( @@ -457,7 +436,6 @@ async function classify( attempt, ...(compacted === undefined ? {} : { compacted }), model: ref, - toolExecution, cutoff, handle, pollAt, @@ -475,10 +453,10 @@ async function classify( return answer(runtime, message, context); } // The retry and compaction policies govern the next attempt, so they are read now rather than pinned at preparation. - const config = await runtime.snapshot(ConversationConfig, conversationId, context); + const settings = runtime.settings; const overflow = message.stopReason === "error" && isContextOverflow(message); - if (overflow && compacted === undefined && (config?.compaction ?? DEFAULT_COMPACTION_POLICY).enabled) { - const policy = config?.compaction ?? DEFAULT_COMPACTION_POLICY; + if (overflow && compacted === undefined && settings.compaction.enabled) { + const policy = settings.compaction; const view = await runtime.context(conversationId, context, cutoff); if (selectCut(view, policy.keepRecentTokens) !== undefined) { const text = message.errorMessage ?? "Context overflow"; @@ -493,7 +471,7 @@ async function classify( return; } } - const policy = config?.retry ?? DEFAULT_RETRY_POLICY; + const policy = settings.retry; // An overflow is never retried: only a compaction can make the next request fit. const retry = message.stopReason === "error" && @@ -537,7 +515,8 @@ async function answer(runtime: Runtime, message: AssistantMessage, context: Cont }); const conversationId = runtime.conversationId; await runtime.commit(async (tx): Promise => { - const boundary = await prepareBoundary(tx, conversationId); + // Queue modes are read on the Session line, when the boundary is decided. + const boundary = await prepareBoundary(tx, conversationId, runtime.settings); const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); const result: Next = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } }; @@ -570,9 +549,14 @@ async function startToolRound( const conversationId = runtime.conversationId; const messages = request.messages ?? (await runtime.context(conversationId, context, request.cutoff)).messages; const offered = new Set(getCurrentTools(messages).map((tool) => tool.name)); + // Read as the round starts; a tool is resolved as its tool task resolves it. + const tools = (await runtime.agent(context)).tools; const sequential = - request.toolExecution === "sequential" || - calls.some((call) => offered.has(call.name) && runtime.registry.tool(call.name)?.executionMode === "sequential"); + runtime.settings.toolExecution === "sequential" || + calls.some( + (call) => + offered.has(call.name) && tools.find((tool) => tool.name === call.name)?.executionMode === "sequential", + ); await runtime.commit(async (tx): Promise => { const live = await tx.doc(LiveDoc, conversationId); const entry = await appendAssistant(tx, conversationId, message); @@ -630,11 +614,8 @@ async function finishToolRound( // The last handoff in call order wins. const handoff = [...controls.values()].findLast((control) => control?.handoff !== undefined)?.handoff; await runtime.commit(async (tx): Promise => { - const boundary = await prepareBoundary(tx, conversationId); - if (added.length > 0) { - const config = await tx.doc(ConversationConfig, conversationId); - for (const name of added) if (!config.activeTools.includes(name)) config.activeTools.push(name); - } + const boundary = await prepareBoundary(tx, conversationId, runtime.settings); + if (added.length > 0) await addTools(tx, conversationId, added); const live = await tx.doc(LiveDoc, conversationId); const now = runtime.now(); if (terminate || handoff !== undefined) { diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts index 1129d1df0..8370613db 100644 --- a/packages/durable/src/harness/harness.ts +++ b/packages/durable/src/harness/harness.ts @@ -1,7 +1,7 @@ -import type { AttachedReplicatedState, Context, Draft, JsonValue } from "@earendil-works/chord"; +import type { AttachedReplicatedState, Context, JsonValue } from "@earendil-works/chord"; import { withAbortSignal, withoutAbortSignal } from "@earendil-works/chord/context"; -import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; import { ResetEntry } from "../entries.ts"; +import type { ExecutionEnv } from "../env/index.ts"; import { SessionImpl } from "../session/session.ts"; import type { Transaction } from "../session/transaction.ts"; import type { @@ -21,21 +21,17 @@ import type { WatchHandle, } from "../types.ts"; import { ROOT_CONVERSATION_ID } from "../types.ts"; +import { AgentDoc, configure, createAgent, resolveAgent, resolveSettings } from "./agent.ts"; import { createCompaction } from "./compaction.ts"; -import { - ConversationConfig, - type ConversationConfigState, - DEFAULT_COMPACTION_POLICY, - DEFAULT_RETRY_POLICY, -} from "./config.ts"; import { readContext } from "./context.ts"; -import { withdrawQueuedInputs } from "./inbox.ts"; -import { settleSchedulerOutcome } from "./live.ts"; -import { BUILTIN_SETUP_KEY, BUILTIN_TASKS } from "./registry.ts"; +import { InboxDoc, withdrawQueuedInputs } from "./inbox.ts"; +import { LiveDoc, settleSchedulerOutcome } from "./live.ts"; +import { BUILTIN_TASKS } from "./registry.ts"; import { type InvocationBinding, TaskScheduler } from "./scheduler.ts"; import { Submissions } from "./submissions.ts"; import type { - CompactionPolicy, + Agent, + AgentChange, CompactionResult, ContextView, Conversation, @@ -43,19 +39,13 @@ import type { ConversationCreateOptions, ConversationHandle, ConversationInit, - ConversationRetryPolicy, - ConversationStreamOptions, HarnessInspection, HarnessOptions, Harness as HarnessType, - ModelRef, - QueueMode, - RegistryReader, RegistrySnapshot, SettledTask, Submission, SubmissionDraft, - ToolExecutionMode, ToolRegistration, } from "./types.ts"; import { addUsageState, UsageDoc, type UsageState } from "./usage.ts"; @@ -74,16 +64,18 @@ type CreateTarget = readonly ownership: ConversationOwnership; }; +/** What the conveniences apply in the creating commit, after the creation hook. */ +type CreateOptions = { readonly agent?: AgentChange; readonly init?: ConversationInit }; + /** Harness-private services used by Conversation handles. */ type ConversationHost = { readonly harness: HarnessImpl; readonly storage: Storage; - readonly registry: RegistryReader; readonly tasks: TaskScheduler; readonly submissions: Submissions; readonly views: ConversationViews; readonly now: () => number; - create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise; + create(target: CreateTarget, options: CreateOptions, context: Context): Promise; }; class ConversationImpl implements Conversation { @@ -95,102 +87,12 @@ class ConversationImpl implements Conversation { this.#host = host; } - async getModel(context: Context): Promise { - return (await this.#config(context)).model; + agent(context: Context): Promise { + return this.#host.harness.resolveAgent(this.id, undefined, context); } - setModel(model: ModelRef | undefined, context: Context): Promise { - return this.#editConfig((config) => { - if (model === undefined) delete config.model; - else config.model = { provider: model.provider, modelId: model.modelId }; - }, context); - } - - async getThinkingLevel(context: Context): Promise { - return (await this.#config(context)).thinkingLevel; - } - - setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise { - return this.#editConfig((config) => { - config.thinkingLevel = level; - }, context); - } - - async getActiveTools(context: Context): Promise { - return (await this.#config(context)).activeTools; - } - - setActiveTools(names: readonly string[], context: Context): Promise { - return this.#editConfig((config) => { - if (new Set(names).size !== names.length) throw new Error("Active tools list a name more than once"); - requireRegistered(this.#host.registry.snapshot(), names, config.activeTools); - config.activeTools = [...names]; - }, context); - } - - async getStreamOptions(context: Context): Promise { - return (await this.#config(context)).streamOptions ?? {}; - } - - setStreamOptions(options: ConversationStreamOptions, context: Context): Promise { - return this.#editConfig((config) => { - config.streamOptions = options; - }, context); - } - - async getRetryPolicy(context: Context): Promise { - return (await this.#config(context)).retry ?? { ...DEFAULT_RETRY_POLICY }; - } - - setRetryPolicy(policy: ConversationRetryPolicy | undefined, context: Context): Promise { - return this.#editConfig((config) => { - if (policy === undefined) delete config.retry; - else config.retry = policy; - }, context); - } - - async getToolExecution(context: Context): Promise { - return (await this.#config(context)).toolExecution ?? "parallel"; - } - - setToolExecution(mode: ToolExecutionMode | undefined, context: Context): Promise { - return this.#editConfig((config) => { - if (mode === undefined) delete config.toolExecution; - else config.toolExecution = mode; - }, context); - } - - async getSteeringMode(context: Context): Promise { - return (await this.#config(context)).steeringMode ?? "one-at-a-time"; - } - - setSteeringMode(mode: QueueMode | undefined, context: Context): Promise { - return this.#editConfig((config) => { - if (mode === undefined) delete config.steeringMode; - else config.steeringMode = mode; - }, context); - } - - async getFollowUpMode(context: Context): Promise { - return (await this.#config(context)).followUpMode ?? "one-at-a-time"; - } - - setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise { - return this.#editConfig((config) => { - if (mode === undefined) delete config.followUpMode; - else config.followUpMode = mode; - }, context); - } - - async getCompaction(context: Context): Promise { - return (await this.#config(context)).compaction ?? { ...DEFAULT_COMPACTION_POLICY }; - } - - setCompaction(policy: CompactionPolicy | undefined, context: Context): Promise { - return this.#editConfig((config) => { - if (policy === undefined) delete config.compaction; - else config.compaction = policy; - }, context); + configure(change: AgentChange, context: Context): Promise { + return this.#host.harness.commitWith((tx) => configure(tx, this.id, change), context); } submit(submission: SubmissionDraft, context: Context): Promise { @@ -235,11 +137,7 @@ class ConversationImpl implements Conversation { } fork(at: EntryId, options: ConversationCreateOptions, context: Context): Promise { - return this.#host.create( - { kind: "fork", parentId: this.id, at, ownership: options.ownership }, - options.init, - context, - ); + return this.#host.create({ kind: "fork", parentId: this.id, at, ownership: options.ownership }, options, context); } abort(context: Context, options?: ConversationAbortOptions): Promise { @@ -259,44 +157,35 @@ class ConversationImpl implements Conversation { watch(context: Context): Promise> { return this.#host.views.watch(this.id, context); } - - async #config(context: Context): Promise> { - return ( - (await this.#host.harness.snapshot(ConversationConfig, this.id, context)) ?? - ConversationConfig.definition.initial() - ); - } - - #editConfig(edit: (config: Draft) => void, context: Context): Promise { - return this.#host.harness.commitWith(async (tx) => { - edit(await tx.doc(ConversationConfig, this.id)); - }, context); - } } /** Session kernel extended with conversation handles and a registry. */ class HarnessImpl extends SessionImpl implements HarnessType { readonly #storage: Storage; - readonly #registry: RegistryReader; + readonly #options: HarnessOptions; + readonly #report: (error: unknown) => void; readonly #host: ConversationHost; readonly #tasks: TaskScheduler; readonly #submissions: Submissions; - readonly #snapshots = new WeakMap>(); #closed = false; constructor(storage: Storage, options: HarnessOptions, context: Context) { super(storage); this.#storage = storage; - this.#registry = options.registry; + this.#options = options; + this.#report = options.onReport ?? (() => {}); const now = options.now ?? Date.now; + const settings = () => resolveSettings(options.settings); this.#tasks = new TaskScheduler({ session: this, storage, registry: options.registry, models: options.models, - env: options.env, + agent: (id, snapshot, callContext) => this.resolveAgent(id, snapshot as RegistrySnapshot, callContext), + settings, + env: (id, callContext) => this.buildEnv(id, callContext), now, - report: options.onReport ?? (() => {}), + report: this.#report, settleOutcome: settleSchedulerOutcome, withdrawInputs: withdrawQueuedInputs, conversation: async (id, binding, callContext) => { @@ -305,19 +194,37 @@ class HarnessImpl extends SessionImpl implements }, context: withoutAbortSignal(context), }); - this.#submissions = new Submissions(this, storage, now, () => this.#tasks.resume()); + this.#submissions = new Submissions(this, storage, now, settings, () => this.#tasks.resume()); this.#host = { harness: this, storage, - registry: options.registry, tasks: this.#tasks, submissions: this.#submissions, views: new ConversationViews(this, storage), now, - create: (target, init, context) => this.#create(target, init, context), + create: (target, createOptions, context) => this.#create(target, createOptions, context), }; } + /** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */ + async resolveAgent( + id: ConversationId, + snapshot: RegistrySnapshot | undefined, + context: Context, + ): Promise> { + const registry = snapshot ?? this.#options.registry.snapshot(); + const state = await this.snapshot(AgentDoc, id, context); + return resolveAgent(state, registry, resolveSettings(this.#options.settings), this.#report); + } + + /** Build a conversation's environment from its current `cwd`; `undefined` without an `env` option. */ + async buildEnv(id: ConversationId, context: Context): Promise { + const build = this.#options.env; + if (build === undefined) return undefined; + const cwd = (await this.snapshot(AgentDoc, id, context))?.cwd; + return build({ conversationId: id, ...(cwd === undefined ? {} : { cwd }), read: this }, context); + } + /** Reconcile surviving `running` tasks to `pending`; part of open. */ openTasks(context: Context): Promise { return this.#tasks.open(context); @@ -336,12 +243,11 @@ class HarnessImpl extends SessionImpl implements inspect(context: Context): Promise { return this.readOnLine(async () => { - const snapshot = this.#registry.snapshot(); - const { scheduling, tasks } = await this.#tasks.inspect(snapshot); + const { scheduling, tasks } = await this.#tasks.inspect(this.#options.registry.snapshot()); const scan = (status: "queued" | "placed") => scanAll((cursor) => this.#storage.scanSubmissions({ status }, SCAN_PAGE_SIZE, cursor, context)); const submissions = [...(await scan("queued")), ...(await scan("placed"))].sort((a, b) => a.id - b.id); - return { scheduling, tasks, submissions, registry: snapshot.failures() }; + return { scheduling, tasks, submissions }; }); } @@ -384,8 +290,11 @@ class HarnessImpl extends SessionImpl implements return total; } - root(context: Context, options?: { readonly init?: ConversationInit }): Promise { - return this.#create({ kind: "root" }, options?.init, context); + root( + context: Context, + options?: { readonly agent?: AgentChange; readonly init?: ConversationInit }, + ): Promise { + return this.#create({ kind: "root" }, options ?? {}, context); } async conversation(id: ConversationId, context: Context): Promise { @@ -395,7 +304,7 @@ class HarnessImpl extends SessionImpl implements } createConversation(options: ConversationCreateOptions, context: Context): Promise { - return this.#create({ kind: "independent", ownership: options.ownership }, options.init, context); + return this.#create({ kind: "independent", ownership: options.ownership }, options, context); } override close(context: Context): Promise { @@ -408,7 +317,7 @@ class HarnessImpl extends SessionImpl implements return this.#tasks.join(); } - async #create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise { + async #create(target: CreateTarget, options: CreateOptions, context: Context): Promise { this.#assertOpen(); const id = await this.commitWith(async (tx) => { if (target.kind === "root" && (await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) { @@ -420,34 +329,23 @@ class HarnessImpl extends SessionImpl implements : target.kind === "fork" ? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership }) : await tx.createConversation({ ownership: target.ownership }); - if (init !== undefined) await this.#runInit(tx, record.id, init); + if (options.agent !== undefined) await configure(tx, record.id, options.agent); + if (options.init !== undefined) await options.init(tx, record.id); return record.id; }, context); return new ConversationImpl(id, this.#host); } - /** Run every registered conversation setup, built-ins first, in each commit that creates or forks a conversation. */ + /** + * The built-in creation hook, in every commit that creates or forks a conversation: empty `pi.live`, `pi.inbox`, and + * `pi.usage`, the conversation's `pi.agent` (see `createAgent()`), then `HarnessOptions.conversationCreated`. + */ protected override async conversationCreated(tx: Transaction, record: ConversationRecord): Promise { - const snapshot = this.#snapshotOf(tx); - for (const { setup } of snapshot.conversationSetups()) await setup(tx, record, snapshot); - } - - /** Run `init` in the creating commit; its writes are trusted, but names it newly activates must be registered. */ - async #runInit(tx: Transaction, id: ConversationId, init: ConversationInit): Promise { - const snapshot = this.#snapshotOf(tx); - const baseline = [...(await tx.doc(ConversationConfig, id)).activeTools]; - await init(tx, id); - requireRegistered(snapshot, (await tx.doc(ConversationConfig, id)).activeTools, baseline); - } - - /** One registry snapshot per commit, shared by its conversation setups and `init` checks. */ - #snapshotOf(tx: Transaction): RegistrySnapshot { - let snapshot = this.#snapshots.get(tx); - if (snapshot === undefined) { - snapshot = this.#registry.snapshot(); - this.#snapshots.set(tx, snapshot); - } - return snapshot; + await tx.doc(LiveDoc, record.id); + await tx.doc(InboxDoc, record.id); + await tx.doc(UsageDoc, record.id); + await createAgent(tx, record); + await this.#options.conversationCreated?.(tx, record); } #assertOpen(): void { @@ -492,18 +390,6 @@ function boundConversation( }; } -/** Reject names newly added relative to `previous` that `snapshot` does not register; existing names are never rechecked. */ -function requireRegistered( - snapshot: RegistrySnapshot, - names: readonly string[], - previous: readonly string[], -): void { - const existing = new Set(previous); - const registered = new Set(snapshot.toolNames()); - const missing = names.filter((name) => !existing.has(name) && !registered.has(name)); - if (missing.length > 0) throw new Error(`Tools are not registered: ${missing.join(", ")}`); -} - /** Durable agent harness over one Session. */ export type Harness = HarnessType; @@ -516,13 +402,10 @@ export const Harness = { ): Promise { context.abortSignal?.throwIfAborted(); const snapshot = options.registry.snapshot(); - const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === undefined).map( - (task) => `task ${task.definition.name}`, - ); - if (!snapshot.conversationSetups().some(({ key }) => key === BUILTIN_SETUP_KEY)) - missing.push("conversation setup pi"); + const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === undefined); if (missing.length > 0) { - throw new Error(`Registry lacks built-in ${missing.join(", ")}; create it with createRegistry()`); + const names = missing.map((task) => task.definition.name).join(", "); + throw new Error(`Registry lacks built-in tasks ${names}; create it with createRegistry()`); } const harness = new HarnessImpl(storage, options, context); try { diff --git a/packages/durable/src/harness/inbox.ts b/packages/durable/src/harness/inbox.ts index b8627072e..7ba380291 100644 --- a/packages/durable/src/harness/inbox.ts +++ b/packages/durable/src/harness/inbox.ts @@ -2,8 +2,7 @@ import type { Draft, JsonRepresentation } from "@earendil-works/chord"; import { defineDoc } from "../documents.ts"; import { UserEntry } from "../entries.ts"; import type { ConversationId, EntryDraft, EntryId, JsonObject, SubmissionId, Tx } from "../types.ts"; -import { ConversationConfig } from "./config.ts"; -import type { QueueMode, UserInput } from "./types.ts"; +import type { QueueMode, Settings, UserInput } from "./types.ts"; /** A queued submission: user input for a run, or a passive entry write. */ export type InboxItem = @@ -24,6 +23,9 @@ export const InboxDoc = defineDoc({ checkpointWhen: (value) => value.items.length === 0, }); +/** The settings a boundary reads, on the Session line. */ +export type QueueModes = Pick; + /** What a boundary reads before the commit's first table write, and the newest head it has seen so far. */ export type Boundary = { readonly conversationId: ConversationId; @@ -41,15 +43,14 @@ export type BoundaryResult = { readonly users: SubmissionId[]; readonly reset: b * Read what a boundary needs. Table reads must precede the commit's first table write, so callers prepare the * boundary at the start of their commit. */ -export async function prepareBoundary(tx: Tx, conversationId: ConversationId): Promise { +export async function prepareBoundary(tx: Tx, conversationId: ConversationId, modes: QueueModes): Promise { const head = (await tx.latestHeadMarker(conversationId))?.head; const inbox = await tx.doc(InboxDoc, conversationId); - const config = await tx.doc(ConversationConfig, conversationId); return { conversationId, inbox, - steeringMode: config.steeringMode ?? "one-at-a-time", - followUpMode: config.followUpMode ?? "one-at-a-time", + steeringMode: modes.steeringMode, + followUpMode: modes.followUpMode, head, }; } diff --git a/packages/durable/src/harness/prompt.ts b/packages/durable/src/harness/prompt.ts index 8b7037cec..2adeef043 100644 --- a/packages/durable/src/harness/prompt.ts +++ b/packages/durable/src/harness/prompt.ts @@ -19,7 +19,7 @@ export function replaySections(messages: readonly Message[]): Map\n...\n`. A + * Render the agent's sections in order. `undefined` omits a section; tagged text is wrapped as `\n...\n`. A * section that throws keeps its shown text, if any, and is reported; errors after `context` is aborted propagate. */ export async function renderSections( @@ -49,19 +49,6 @@ export async function renderSections( type SystemDraft = TypedEntryDraft; -/** Active names the snapshot resolves, first occurrence of each, in configured order, as composed by wrappers. */ -export function desiredTools( - activeTools: readonly string[], - resolve: (name: string) => T | undefined, -): T[] { - const tools = new Map(); - for (const name of activeTools) { - const tool = tools.has(name) ? undefined : resolve(name); - if (tool !== undefined) tools.set(name, tool); - } - return [...tools.values()]; -} - type ToolChanges = { readonly toolsRemoved: ToolReference[]; readonly toolsAdded: Tool[] }; /** diff --git a/packages/durable/src/harness/registry.ts b/packages/durable/src/harness/registry.ts index 83daa729a..a30b9413f 100644 --- a/packages/durable/src/harness/registry.ts +++ b/packages/durable/src/harness/registry.ts @@ -1,260 +1,65 @@ -import type { ConversationRecord, Tx } from "../types.ts"; +import { INSTRUCTIONS_KEY } from "./agent.ts"; import { CompactionTask } from "./compaction.ts"; -import { ConversationConfig } from "./config.ts"; import { GenerationTask } from "./generation.ts"; -import { InboxDoc } from "./inbox.ts"; -import { LiveDoc } from "./live.ts"; import { ToolTask } from "./tool.ts"; -import type { - AnyTask, - ConversationSetup, - HookRegistration, - HookScope, - HooksOf, - PromptSection, - PromptSectionWrapper, - Registration, - Registry, - RegistryFailure, - RegistrySnapshot, - ToolRegistration, - ToolWrapper, -} from "./types.ts"; -import { UsageDoc } from "./usage.ts"; +import type { AnyTask, Extension, PromptSection, Registry, RegistrySnapshot, ToolRegistration } from "./types.ts"; const SECTION_KEY = /^[a-z][a-z0-9_-]*$/; -/** Built-in task definitions every registry starts with; they cannot be disposed or replaced. */ +/** Built-in task definitions every registry holds; they are not an extension and cannot be removed or replaced. */ export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask, ToolTask, CompactionTask]; -export const BUILTIN_SETUP_KEY = "pi"; - -/** - * Built-in documents: empty `pi.live`, `pi.inbox`, and `pi.usage`, and for a new conversation the default - * configuration with every registered tool active. - */ -async function builtinSetup(tx: Tx, conversation: ConversationRecord, registry: RegistrySnapshot): Promise { - await tx.doc(LiveDoc, conversation.id); - await tx.doc(InboxDoc, conversation.id); - await tx.doc(UsageDoc, conversation.id); - if (conversation.parent !== undefined) return; - (await tx.doc(ConversationConfig, conversation.id)).activeTools = [...registry.toolNames()]; -} - -type Slot = - | { readonly kind: "tool"; readonly tool: Tool } - | { readonly kind: "toolWrap"; readonly name: string; readonly wrapper: ToolWrapper } - | { readonly kind: "hook"; readonly taskName: string; readonly hook: StoredHook } - | { readonly kind: "task"; readonly task: AnyTask } - | { readonly kind: "setup"; readonly key: string; readonly setup: ConversationSetup } - | { readonly kind: "section"; readonly section: PromptSection } - | { readonly kind: "sectionWrap"; readonly key: string; readonly wrapper: PromptSectionWrapper }; - -/** Hook handlers erased across task definitions; lookup by task token restores their type. */ -type StoredHook = { - readonly handlers: unknown; - readonly scope?: HookScope; -}; - -/** One registration and its lifecycle. */ -type RegistryRecord = { - readonly slot: Slot; - /** Uniqueness and position key; absent for keyless hooks, which always append. */ - readonly key: string | undefined; - /** Human-readable identity for duplicate errors. */ - readonly label: string; - position: number; - status: "staged" | "published" | "disposed" | "rejected"; -}; - -type Batch = { - /** Records to publish; a record disposed while staged is removed. */ - readonly added: RegistryRecord[]; - readonly disposed: Set>; -}; - -type Composition = { - readonly tools: readonly Tool[]; - readonly toolsByName: ReadonlyMap; - readonly sections: readonly PromptSection[]; - readonly failures: readonly RegistryFailure[]; -}; - -/** Immutable published registry state; wrappers are composed lazily once per state. */ +/** Immutable published registry state. */ class RegistryState implements RegistrySnapshot { - readonly records: readonly RegistryRecord[]; - #composition: Composition | undefined; + readonly #extensions: readonly Extension[]; + readonly #byName: ReadonlyMap>; + readonly #tasks: ReadonlyMap; - constructor(records: readonly RegistryRecord[]) { - this.records = records; + constructor(extensions: readonly Extension[]) { + this.#extensions = extensions; + this.#byName = new Map(extensions.map((extension) => [extension.name, extension])); + const tasks = new Map(); + for (const task of BUILTIN_TASKS) tasks.set(task.definition.name, task); + for (const extension of extensions) { + for (const task of extension.tasks ?? []) { + const name = task.definition.name; + if (tasks.has(name)) throw new Error(`Task ${name} of extension ${extension.name} is already installed`); + tasks.set(name, task); + } + } + this.#tasks = tasks; } - tools(): readonly Tool[] { - return this.#composed().tools; + installed(): readonly Extension[] { + return this.#extensions; } - tool(name: string): Tool | undefined { - return this.#composed().toolsByName.get(name); + extension(name: string): Extension | undefined { + return this.#byName.get(name); } - toolNames(): readonly string[] { - const names: string[] = []; - for (const record of this.records) if (record.slot.kind === "tool") names.push(record.slot.tool.name); - return names; + tools(): readonly { readonly extension: Extension; readonly tool: Tool }[] { + return this.#extensions.flatMap((extension) => (extension.tools ?? []).map((tool) => ({ extension, tool }))); + } + + sections(): readonly { readonly extension: Extension; readonly section: PromptSection }[] { + return this.#extensions.flatMap((extension) => + (extension.sections ?? []).map((section) => ({ extension, section })), + ); + } + + tasks(): readonly AnyTask[] { + return [...this.#tasks.values()]; } task(name: string): AnyTask | undefined { - for (const record of this.records) { - if (record.slot.kind === "task" && record.slot.task.definition.name === name) return record.slot.task; - } - return undefined; - } - - conversationSetups(): readonly { readonly key: string; readonly setup: ConversationSetup }[] { - const setups: { key: string; setup: ConversationSetup }[] = []; - for (const record of this.records) if (record.slot.kind === "setup") setups.push(record.slot); - return setups; - } - - tasks(): AnyTask[] { - const tasks: AnyTask[] = []; - for (const record of this.records) if (record.slot.kind === "task") tasks.push(record.slot.task); - return tasks; - } - - hooks(task: K): readonly HookRegistration[] { - // Registration was typed by a token with this name; matching by name lets hooks survive a task reload. - const hooks: StoredHook[] = []; - for (const record of this.records) { - if (record.slot.kind === "hook" && record.slot.taskName === task.definition.name) hooks.push(record.slot.hook); - } - return hooks as readonly HookRegistration[]; - } - - sections(): readonly PromptSection[] { - return this.#composed().sections; - } - - failures(): readonly RegistryFailure[] { - return this.#composed().failures; - } - - #composed(): Composition { - if (this.#composition !== undefined) return this.#composition; - const failures: RegistryFailure[] = []; - const toolWraps = new Map[]>(); - const sectionWraps = new Map[]>(); - for (const record of this.records) { - const slot = record.slot; - if (slot.kind === "toolWrap") appendTo(toolWraps, slot.name, slot.wrapper); - if (slot.kind === "sectionWrap") appendTo(sectionWraps, slot.key, slot.wrapper); - } - const tools: Tool[] = []; - const toolsByName = new Map(); - const sections: PromptSection[] = []; - for (const record of this.records) { - const slot = record.slot; - if (slot.kind === "tool") { - const name = slot.tool.name; - try { - let tool = slot.tool; - for (const wrap of toolWraps.get(name) ?? []) { - tool = wrap(tool); - if (tool.name !== name) throw new Error(`Tool wrapper renamed ${name} to ${tool.name}`); - } - tools.push(tool); - toolsByName.set(name, tool); - } catch (error) { - failures.push({ kind: "tool", name, error }); - } - } else if (slot.kind === "section") { - const key = slot.section.key; - try { - let section = slot.section; - for (const wrap of sectionWraps.get(key) ?? []) { - section = wrap(section); - if (section.key !== key) throw new Error(`Section wrapper renamed ${key} to ${section.key}`); - } - sections.push(section); - } catch (error) { - failures.push({ kind: "section", name: key, error }); - } - } - } - this.#composition = { tools, toolsByName, sections, failures }; - return this.#composition; + return this.#tasks.get(name); } } class RegistryImpl implements Registry { #current = new RegistryState([]); - #batch: Batch | undefined; - /** First position of every key ever published; re-registered keys keep it. */ - readonly #positions = new Map(); readonly #listeners = new Set<() => void>(); - #nextPosition = 0; - - readonly tools: Registry["tools"]; - readonly hooks: Registry["hooks"]; - readonly tasks: Registry["tasks"]; - readonly conversations: Registry["conversations"]; - readonly systemPrompt: Registry["systemPrompt"]; - - constructor() { - this.tools = { - add: (tool) => this.#register({ kind: "tool", tool }, `tool\0${tool.name}`, `Tool ${tool.name}`), - wrap: (name, key, wrapper) => - this.#register( - { kind: "toolWrap", name, wrapper }, - `toolWrap\0${name}\0${key}`, - `Tool wrapper ${key} for ${name}`, - ), - list: () => this.#current.tools(), - }; - this.hooks = { - add: ( - task: K, - handlers: Partial>, - options?: { readonly scope?: HookScope; readonly key?: string }, - ) => { - const taskName = task.definition.name; - const hook: StoredHook = options?.scope === undefined ? { handlers } : { handlers, scope: options.scope }; - const key = options?.key; - return this.#register( - { kind: "hook", taskName, hook }, - key === undefined ? undefined : `hook\0${taskName}\0${key}`, - `Hook ${key} for ${taskName}`, - ); - }, - }; - this.tasks = { - add: (task) => { - const name = task.definition.name; - return this.#register({ kind: "task", task }, `task\0${name}`, `Task ${name}`); - }, - list: () => this.#current.tasks(), - }; - this.conversations = { - setup: (key, setup) => this.#register({ kind: "setup", key, setup }, `setup\0${key}`, `Setup ${key}`), - }; - this.systemPrompt = { - section: (key, render, options) => { - requireSectionKey(key); - const section: PromptSection = - options?.tag === undefined ? { key, render } : { key, render, tag: options.tag }; - return this.#register({ kind: "section", section }, `section\0${key}`, `Section ${key}`); - }, - wrap: (key, wrapperKey, wrapper) => { - requireSectionKey(key); - return this.#register( - { kind: "sectionWrap", key, wrapper }, - `sectionWrap\0${key}\0${wrapperKey}`, - `Section wrapper ${wrapperKey} for ${key}`, - ); - }, - sections: () => this.#current.sections(), - }; - } snapshot(): RegistrySnapshot { return this.#current; @@ -265,119 +70,45 @@ class RegistryImpl implements Registry { return () => this.#listeners.delete(listener); } - batch(register: () => void): Registration { - if (this.#batch !== undefined) throw new Error("Registry batches cannot be nested"); - const batch: Batch = { added: [], disposed: new Set() }; - this.#batch = batch; - let added: RegistryRecord[]; - try { - rejectThenable(register()); - added = [...batch.added]; - this.#batch = undefined; - this.#publish(batch); - } catch (error) { - this.#batch = undefined; - for (const record of batch.added) record.status = "rejected"; - throw error; - } - return { - dispose: () => { - if (this.#batch !== undefined) { - for (const record of added) this.#dispose(record); - return; - } - this.batch(() => { - for (const record of added) this.#dispose(record); - }); - }, - }; + install(extension: Extension): void { + validateExtension(extension); + const current = this.#current.installed(); + const index = current.findIndex((installed) => installed.name === extension.name); + const next = + index < 0 ? [...current, extension] : current.map((installed, at) => (at === index ? extension : installed)); + this.#publish(next); } - #register(slot: Slot, key: string | undefined, label: string): Registration { - const record: RegistryRecord = { slot, key, label, position: -1, status: "staged" }; - const batch = this.#batch; - if (batch !== undefined) { - batch.added.push(record); - } else { - try { - this.#publish({ added: [record], disposed: new Set() }); - } catch (error) { - record.status = "rejected"; - throw error; - } - } - return { dispose: () => this.#dispose(record) }; + uninstall(extension: Extension): void { + const current = this.#current.installed(); + if (!current.some((installed) => installed.name === extension.name)) return; + this.#publish(current.filter((installed) => installed.name !== extension.name)); } - #dispose(record: RegistryRecord): void { - const batch = this.#batch; - if (record.status === "staged") { - const index = batch?.added.indexOf(record) ?? -1; - if (index < 0) return; - batch!.added.splice(index, 1); - record.status = "disposed"; - } else if (record.status === "published") { - if (batch !== undefined) batch.disposed.add(record); - else this.#publish({ added: [], disposed: new Set([record]) }); - } - } - - /** Validate the final staged state, then publish it synchronously. */ - #publish(batch: Batch): void { - if (batch.added.length === 0 && batch.disposed.size === 0) return; - const records = [...this.#current.records.filter((record) => !batch.disposed.has(record)), ...batch.added]; - const keys = new Set(); - for (const record of records) { - if (record.key === undefined) continue; - if (keys.has(record.key)) throw new Error(`${record.label} is already registered`); - keys.add(record.key); - } - for (const record of batch.added) { - if (record.key === undefined) { - record.position = this.#nextPosition++; - continue; - } - let position = this.#positions.get(record.key); - if (position === undefined) { - position = this.#nextPosition++; - this.#positions.set(record.key, position); - } - record.position = position; - } - records.sort((left, right) => left.position - right.position); - this.#current = new RegistryState(records); - for (const record of batch.added) record.status = "published"; - for (const record of batch.disposed) record.status = "disposed"; + /** Build and validate the next state, which throws on a task name collision, then publish it synchronously. */ + #publish(extensions: readonly Extension[]): void { + this.#current = new RegistryState(extensions); for (const listener of [...this.#listeners]) listener(); } } -/** Create an application-owned registry holding only the built-ins. */ -export function createRegistry(): Registry { - const registry = new RegistryImpl(); - // Their registrations are dropped, so nothing can dispose them. - for (const task of BUILTIN_TASKS) registry.tasks.add(task); - registry.conversations.setup(BUILTIN_SETUP_KEY, builtinSetup); - return registry; -} - -function appendTo(map: Map, key: string, value: T): void { - const list = map.get(key); - if (list === undefined) map.set(key, [value]); - else list.push(value); -} - -function requireSectionKey(key: string): void { - if (!SECTION_KEY.test(key)) throw new TypeError(`Section key ${JSON.stringify(key)} must match ${SECTION_KEY}`); -} - -function rejectThenable(result: unknown): void { - if ( - (typeof result === "object" || typeof result === "function") && - result !== null && - typeof (result as { then?: unknown }).then === "function" - ) { - (result as PromiseLike).then(undefined, () => undefined); - throw new TypeError("Registry batch callbacks must be synchronous"); +/** Unique tool names and section keys within one extension; valid, unreserved section keys. */ +function validateExtension(extension: Extension): void { + const tools = new Set(); + for (const tool of extension.tools ?? []) { + if (tools.has(tool.name)) throw new Error(`Extension ${extension.name} has two tools named ${tool.name}`); + tools.add(tool.name); + } + const sections = new Set(); + for (const { key } of extension.sections ?? []) { + if (!SECTION_KEY.test(key)) throw new TypeError(`Section key ${JSON.stringify(key)} must match ${SECTION_KEY}`); + if (key === INSTRUCTIONS_KEY) throw new Error(`Section key ${key} is reserved for the agent's instructions`); + if (sections.has(key)) throw new Error(`Extension ${extension.name} has two sections with key ${key}`); + sections.add(key); } } + +/** Create an application-owned registry holding only the built-in tasks. */ +export function createRegistry(): Registry { + return new RegistryImpl(); +} diff --git a/packages/durable/src/harness/scheduler.ts b/packages/durable/src/harness/scheduler.ts index c083dc1e8..c8e21c38d 100644 --- a/packages/durable/src/harness/scheduler.ts +++ b/packages/durable/src/harness/scheduler.ts @@ -22,14 +22,16 @@ import type { TaskRuntime, TaskState, } from "../types.ts"; +import { agentHooks } from "./agent.ts"; import { readContext } from "./context.ts"; import type { + Agent, AnyTask, ConversationHandle, HarnessInspection, - HookScope, RegistryReader, RegistrySnapshot, + Settings, SettledTask, TaskInspection, } from "./types.ts"; @@ -78,6 +80,13 @@ type Invocation = { readonly finish: () => void; }; +/** What a runtime reads for the phase handler it serves: the phase's snapshot and task, and its lazily resolved agent. */ +type Phase = { + readonly snapshot: () => RegistrySnapshot; + readonly task: () => AnyTask; + agent: Promise | undefined; +}; + type Reservation = { readonly invocation: Invocation; readonly task: AnyTask; @@ -125,7 +134,12 @@ export type TaskSchedulerOptions = { readonly storage: Storage; readonly registry: RegistryReader; readonly models: Models; - readonly env: ExecutionEnv | undefined; + /** Resolve a conversation's agent against a snapshot; the runtime calls it at most once per phase. */ + readonly agent: (conversationId: ConversationId, snapshot: RegistrySnapshot, context: Context) => Promise; + /** Resolve the settings; read at each access. */ + readonly settings: () => Settings; + /** Build a conversation's environment with `HarnessOptions.env`. */ + readonly env: (conversationId: ConversationId, context: Context) => Promise; readonly now: () => number; readonly report: (error: unknown) => void; /** Harness cleanup staged in the commit that makes an outcome the scheduler wrote itself terminal. */ @@ -162,7 +176,9 @@ export class TaskScheduler { readonly #storage: Storage; readonly #registry: RegistryReader; readonly #models: Models; - readonly #env: ExecutionEnv | undefined; + readonly #agent: TaskSchedulerOptions["agent"]; + readonly #settings: TaskSchedulerOptions["settings"]; + readonly #env: TaskSchedulerOptions["env"]; readonly #now: () => number; readonly #report: (error: unknown) => void; readonly #settleOutcome: TaskSchedulerOptions["settleOutcome"]; @@ -200,6 +216,8 @@ export class TaskScheduler { this.#storage = options.storage; this.#registry = options.registry; this.#models = options.models; + this.#agent = options.agent; + this.#settings = options.settings; this.#env = options.env; this.#now = options.now; this.#report = options.report; @@ -835,17 +853,16 @@ export class TaskScheduler { async #run(reservation: Reservation): Promise { const invocation = reservation.invocation; const state = { task: reservation.task, snapshot: reservation.snapshot, reported: undefined as ReportedTask }; - const runtime = this.#runtime( - invocation, - () => state.snapshot, - () => state.task, - ); + const phase: Phase = { snapshot: () => state.snapshot, task: () => state.task, agent: undefined }; + const runtime = this.#runtime(invocation, phase); let previous: PhaseResult | undefined; for (;;) { const current = await this.#step(invocation, (tx, current) => this.#decide(tx, current, previous, state)); // Close may seal between the decision and dispatch. if (current === undefined || this.#closing) return; const checkpoint = current.state.checkpoint; + // Each phase handler resolves its agent afresh, at first use. + phase.agent = undefined; try { await erased(state.task).phases[checkpoint.phase]!(current, runtime, invocation.context); previous = { checkpoint }; @@ -901,11 +918,8 @@ export class TaskScheduler { if (current === undefined || this.#closing) return; let failure: { readonly error: unknown } | undefined; try { - const runtime = this.#runtime( - invocation, - () => reservation.snapshot, - () => reservation.task, - ); + const phase: Phase = { snapshot: () => reservation.snapshot, task: () => reservation.task, agent: undefined }; + const runtime = this.#runtime(invocation, phase); await erased(reservation.task).abort(current, runtime, invocation.context); } catch (error) { failure = { error }; @@ -1031,14 +1045,22 @@ export class TaskScheduler { // ─── Invocation runtime ────────────────────────────────────────────────── - #runtime(invocation: Invocation, snapshot: () => RegistrySnapshot, task: () => AnyTask): ErasedRuntime { + #runtime(invocation: Invocation, phase: Phase): ErasedRuntime { + // Resolved at most once per phase handler, at first use, with the invocation's context; fixed for the phase. + const agent = (): Promise => { + if (invocation.ended) return Promise.reject(endedError(invocation)); + if (phase.agent === undefined) { + phase.agent = this.#agent(invocation.conversationId, phase.snapshot(), invocation.context); + // A caller that stops waiting must not leave the shared resolution's failure unobserved. + phase.agent.catch(() => {}); + } + return phase.agent; + }; const hooks: HookRunner> = { each: async (name, invoke) => { - if (invocation.ended) throw endedError(invocation); - for (const { handlers, scope } of snapshot().hooks(task())) { + for (const handlers of agentHooks(await agent(), phase.task().definition.name)) { const handler = (handlers as Record)[name]; if (typeof handler !== "function") continue; - if (scope !== undefined && !(await this.#hookMatches(invocation, scope))) continue; try { await invoke(handler.bind(handlers)); } catch (error) { @@ -1048,15 +1070,21 @@ export class TaskScheduler { } }, }; + const settings = this.#settings; return { taskId: invocation.taskId as TaskId, conversationId: invocation.conversationId, signal: invocation.controller.signal, models: this.#models, - env: this.#env, + agent: (context) => + invocation.ended ? Promise.reject(endedError(invocation)) : awaitWithContext(agent(), context), + get settings() { + return settings(); + }, + env: (context) => this.#read(invocation, () => this.#env(invocation.conversationId, context)), hooks: hooks as ErasedRuntime["hooks"], get registry() { - return snapshot(); + return phase.snapshot(); }, commit: (change, context) => this.#gated( @@ -1146,18 +1174,6 @@ export class TaskScheduler { }; } - /** Whether a scoped hook registration matches the invocation's conversation: itself, or an owner for `subtree`. */ - async #hookMatches(invocation: Invocation, scope: HookScope): Promise { - if (scope.conversationId === invocation.conversationId) return true; - if (scope.subtree !== true) return false; - const start = { conversation: invocation.conversationId }; - if (!this.#chainKnown(start)) await this.#session.readOnLine(() => this.#loadChain(start)); - for (const step of this.#above(start)) { - if ("conversation" in step && step.conversation === scope.conversationId) return true; - } - return false; - } - /** Run a committed-state read unless the invocation has ended. */ async #read(invocation: Invocation, read: () => Promise): Promise { if (invocation.ended) throw endedError(invocation); diff --git a/packages/durable/src/harness/submissions.ts b/packages/durable/src/harness/submissions.ts index eb8bfd357..496b6cbaa 100644 --- a/packages/durable/src/harness/submissions.ts +++ b/packages/durable/src/harness/submissions.ts @@ -2,7 +2,6 @@ import { type Context, copyJson, type JsonRepresentation } from "@earendil-works import { UserEntry } from "../entries.ts"; import { ConversationBusy } from "../errors.ts"; import type { SessionImpl } from "../session/session.ts"; -import type { Transaction } from "../session/transaction.ts"; import type { CommitPublication, ConversationId, @@ -10,9 +9,10 @@ import type { Storage, SubmissionId, SubmissionRecord, + Tx, } from "../types.ts"; import { startRun } from "./generation.ts"; -import { applyBoundary, InboxDoc, isStale, prepareBoundary, removeInboxItem } from "./inbox.ts"; +import { applyBoundary, InboxDoc, isStale, prepareBoundary, type QueueModes, removeInboxItem } from "./inbox.ts"; import { LiveDoc } from "./live.ts"; import type { SettledSubmissionRecord, Submission, SubmissionDraft, UserInput } from "./types.ts"; import { closedError, Waiters } from "./util.ts"; @@ -24,15 +24,24 @@ export class Submissions { readonly #session: SessionImpl; readonly #storage: Storage; readonly #now: () => number; + /** Read at each admission, on the Session line. */ + readonly #queueModes: () => QueueModes; /** Enable task scheduling; submitting or waiting asks for progress. */ readonly #resume: () => void; readonly #waiters = new Waiters(); #closed = false; - constructor(session: SessionImpl, storage: Storage, now: () => number, resume: () => void) { + constructor( + session: SessionImpl, + storage: Storage, + now: () => number, + queueModes: () => QueueModes, + resume: () => void, + ) { this.#session = session; this.#storage = storage; this.#now = now; + this.#queueModes = queueModes; this.#resume = resume; session.subscribeCommits((publication) => this.#observe(publication)); session.subscribeClose(() => { @@ -45,7 +54,7 @@ export class Submissions { async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise { this.#resume(); const id = await this.#session.commitWith( - (tx) => admitSubmission(tx, conversationId, draft, this.#now()), + (tx) => admitSubmission(tx, conversationId, draft, this.#now(), this.#queueModes()), context, ); return new SubmissionHandle(id, this); @@ -137,10 +146,11 @@ function isSettled(record: SubmissionRecord): record is SettledSubmissionRecord * its entry and settles `done`, or `stale` when its head reaches before the active range. */ export async function admitSubmission( - tx: Transaction, + tx: Tx, conversationId: ConversationId, draft: SubmissionDraft, now: number, + queueModes: QueueModes, ): Promise { if (draft.requestId !== undefined) { const existing = await tx.submissionByRequest(conversationId, draft.requestId); @@ -156,7 +166,7 @@ export async function admitSubmission( if (busy && draft.type === "input" && draft.whenBusy === "reject") throw new ConversationBusy(conversationId); const requestId = draft.requestId === undefined ? {} : { requestId: draft.requestId }; // A boundary reads the table, so it is prepared before the first table write; a busy one needs none. - const boundary = busy ? undefined : await prepareBoundary(tx, conversationId); + const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes); if (boundary === undefined || boundary.inbox.items.length > 0) { const { id } = await tx.createSubmission({ conversationId, diff --git a/packages/durable/src/harness/tool.ts b/packages/durable/src/harness/tool.ts index 4cd172127..76d106ab1 100644 --- a/packages/durable/src/harness/tool.ts +++ b/packages/durable/src/harness/tool.ts @@ -43,7 +43,7 @@ type Runtime = TaskRuntime { const call = await readCall(runtime, task.input, context); - const tool = runtime.registry.tool(call.name); + const tool = (await runtime.agent(context)).tools.find((each) => each.name === call.name); if (tool === undefined) { const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`); return settle(runtime, call, COMPLETED, () => error, context); @@ -94,7 +94,7 @@ export const ToolTask = defineTask { const { arguments: args, replay } = task.state.checkpoint; const call = await readCall(runtime, task.input, context); - const tool = runtime.registry.tool(call.name); + const tool = (await runtime.agent(context)).tools.find((each) => each.name === call.name); if (replay === "safe" && tool?.replay === "safe") { // The rerun reports from scratch; clear what the interrupted attempt published. await runtime.commit(async (tx) => { @@ -184,11 +184,12 @@ async function run( const assertLive = (): void => { if (ended) throw new Error(`Tool call ${call.id} has settled`); }; - const api: ToolExecutionApi = { + const api: Omit = { taskId: runtime.taskId, conversationId: runtime.conversationId, callId: call.id, - env: runtime.env, + registry: runtime.registry, + agent: runtime.agent, output: (chunk) => { assertLive(); if (reported.output.push(chunk)) progress.mark(); @@ -240,7 +241,9 @@ async function run( let result: ToolExecutionResult; let ending = COMPLETED; try { - result = await tool.execute(args, api, context); + // Built for this call, so a rerun after recovery gets the conversation's environment at that time. + const env = await runtime.env(context); + result = await tool.execute(args, { ...api, env }, context); } catch (error) { if (runtime.signal.aborted) { ended = true; @@ -248,7 +251,7 @@ async function run( throw error; } result = { isError: true, diagnostics: [toolDiagnostic("tool_error", errorText(error))] }; - // A throw ends the task `failed`, which cancels what the call owned; it no longer supervises it. The error text + // A throw, from `execute()` or from building the environment, ends the task `failed`, which cancels what the call owned; it no longer supervises it. The error text // is already in the result entry. ending = { status: "failed", message: `Tool ${call.name} threw` }; } diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts index 18a6535aa..abd499f33 100644 --- a/packages/durable/src/harness/types.ts +++ b/packages/durable/src/harness/types.ts @@ -5,10 +5,12 @@ import type { Message, Models, ModelThinkingLevel, + Static, Tool, ToolCall, ToolResultMessage, Transport, + TSchema, Usage, UserMessage, } from "@earendil-works/pi-ai"; @@ -137,12 +139,12 @@ export type ToolDiagnostic = { readonly code?: string; }; -export type ToolExecutionResult = { +export type ToolExecutionResult = { /** Omitted: the retained `output()` text becomes the content. */ readonly content?: ToolResultMessage["content"]; readonly isError?: boolean; /** Omitted: the last `details()` value becomes the details. */ - readonly details?: JsonValue; + readonly details?: TDetails; /** Added after those recorded through `api.diagnostic()`. */ readonly diagnostics?: readonly ToolDiagnostic[]; /** Spend of the execution itself, such as a model call; stored on the result and in `pi.usage.tools`. */ @@ -157,21 +159,25 @@ export type ToolExecutionMode = "parallel" | "sequential"; export type QueueMode = "all" | "one-at-a-time"; /** - * Operations available to one tool invocation. A plain object, so a wrapper can pass `{ ...api, env }` to the tool it - * wraps. Every operation rejects after the invocation ends. + * Operations available to one tool invocation. A plain object, so a wrapper can spread it. Every operation rejects after + * the invocation ends. */ -export interface ToolExecutionApi extends DocumentObserver, DocumentReader { +export interface ToolExecutionApi extends DocumentObserver, DocumentReader { readonly taskId: TaskId; readonly conversationId: ConversationId; readonly callId: string; - /** `HarnessOptions.env` unless a wrapper supplies another environment. */ + /** The tool task's phase snapshot. */ + readonly registry: RegistrySnapshot; + /** The calling conversation's agent, as the tool task's phase resolved it. */ + agent(context: Context): Promise; + /** Built by `HarnessOptions.env` for this call; `undefined` without an environment. */ readonly env: ExecutionEnv | undefined; /** Append running output; it becomes the result content when the result omits `content`. */ output(chunk: string | Uint8Array): void; /** Record a model-visible remark about this call. */ diagnostic(diagnostic: ToolDiagnostic): void; /** Replace running details; the last value becomes the result details when the result omits `details`. */ - details(value: JsonValue, context: Context): Promise; + details(value: TDetails, context: Context): Promise; commit(change: (tx: Tx) => T | Promise, context: Context): Promise; memo(name: string, context: Context): Promise; memo(name: string, candidate: T, context: Context): Promise; @@ -187,95 +193,87 @@ export interface ToolExecutionApi extends DocumentObserver, DocumentReader { conversation(id: ConversationId, context: Context): Promise; } -/** Executable tool registered in a registry. Only pi-ai `Tool` fields enter the transcript. */ -export type ToolRegistration = Tool & { +/** + * Executable tool registered in a registry. Only pi-ai `Tool` fields enter the transcript. `args` are typed by + * `parameters`, which the Harness validates them against before `execute()`; `defineTool()` infers both generics. + */ +export type ToolRegistration< + TParameters extends TSchema = TSchema, + TDetails extends JsonValue = JsonValue, +> = Tool & { /** Whether an interrupted execution may rerun on recovery. Default `unsafe`. */ readonly replay?: "safe" | "unsafe"; - /** Default: the conversation's `toolExecution`. One sequential call makes its whole round sequential. */ + /** Default: the settings' `toolExecution`. One sequential call makes its whole round sequential. */ readonly executionMode?: ToolExecutionMode; /** * Repair arguments models commonly get wrong before validation, such as a JSON string where an array belongs. Must be - * pure and must not mutate `args`: it runs again when a call is retried before its intent is recorded. + * pure and must not mutate `args`: it runs again when a call is retried before its intent is recorded. Its result is + * still validated against `parameters`. */ - prepareArguments?(args: JsonValue): JsonValue; + prepareArguments?(args: unknown): Static; readonly outputLimits?: { readonly maxBytes?: number; readonly maxLines?: number; readonly retain?: "head" | "tail"; }; - execute(args: JsonValue, api: ToolExecutionApi, context: Context): Promise; -}; - -/** Token for removing registrations. Work already running keeps using what it started with. */ -export interface Registration { - /** Idempotent; removes exactly the registrations this token covers. */ - dispose(): void; -} - -/** Pure decorator; returns a new tool with the same name and never mutates its input. */ -export type ToolWrapper = (tool: Tool) => Tool; - -/** Conversation selection for a scoped hook registration. */ -export type HookScope = { - readonly conversationId: ConversationId; - /** Also match conversations owned, transitively, by tasks of this conversation. */ - readonly subtree?: boolean; + execute( + args: Static, + api: ToolExecutionApi, + context: Context, + ): Promise>; }; /** Input to system prompt section rendering for one request preparation. */ -export type PromptInput = { +export type PromptInput = { readonly conversationId: ConversationId; - /** Active and registered tools in configured order, as offered in this request. */ - readonly tools: readonly Tool[]; + /** The request's resolution; `agent.tools` are the tools offered in this request. */ + readonly agent: Agent; + /** Built by `HarnessOptions.env` for this preparation; `undefined` without an environment. */ + readonly env: ExecutionEnv | undefined; /** Sections already in effect after replaying the active transcript. */ readonly shown: Readonly>; - readonly model?: ModelRef; - readonly thinkingLevel: ModelThinkingLevel; /** Committed document reads. */ readonly read: DocumentReader; }; -/** One registered system prompt section; sections render in registry order before each request. */ -export type PromptSection = { +/** One system prompt section; the agent's sections render in order before each request. */ +export type PromptSection = { readonly key: string; render(input: PromptInput, context: Context): string | undefined | Promise; /** Default true: wrap the text as `\n...\n`. */ readonly tag?: boolean; }; -/** Pure decorator; returns a new section with the same key and never mutates its input. */ -export type PromptSectionWrapper = (section: PromptSection) => PromptSection; +/** Built by `hook()`; matches tasks by name. */ +export type HookRegistration = { readonly task: string; readonly handlers: object }; -/** One registered hook handler map for task `K`. */ -export type HookRegistration = { - readonly handlers: Partial>; - readonly scope?: HookScope; -}; +/** Built by `wrapTool()` and `wrapSection()`; targets a tool name or a section key. Wrappers are pure. */ +export type Wrap = + | { readonly tool: string; wrap(tool: Tool): Tool } + | { readonly section: string; wrap(section: PromptSection): PromptSection }; -/** Wrapper composition failure found while building a snapshot. */ -export type RegistryFailure = { - readonly kind: "tool" | "section"; - /** Tool name or section key. */ +/** Named bundle of code; installed in a registry and selected by conversations by name. */ +export interface Extension { readonly name: string; - readonly error: unknown; -}; + readonly tools?: readonly Tool[]; + readonly sections?: readonly PromptSection[]; + readonly hooks?: readonly HookRegistration[]; + /** Apply where this extension is selected, in order. */ + readonly wraps?: readonly Wrap[]; + /** Resolved by name for every task, whichever conversations select this extension. */ + readonly tasks?: readonly AnyTask[]; +} /** Immutable view of one published registry state. */ export interface RegistrySnapshot { - /** Composed tools in registry order; a tool whose wrapper failed is absent. */ - tools(): readonly Tool[]; - tool(name: string): Tool | undefined; - /** Base tool names in registry order, including tools whose wrappers fail. */ - toolNames(): readonly string[]; + installed(): readonly Extension[]; + extension(name: string): Extension | undefined; + /** Every installed tool with its extension, in install order. Names may repeat across extensions. */ + tools(): readonly { readonly extension: Extension; readonly tool: Tool }[]; + sections(): readonly { readonly extension: Extension; readonly section: PromptSection }[]; + /** Built-in and installed task definitions. */ + tasks(): readonly AnyTask[]; task(name: string): AnyTask | undefined; - /** Hooks registered for tasks with `task`'s name, in registry order. */ - hooks(task: K): readonly HookRegistration[]; - /** Composed sections in registry order; a section whose wrapper failed is absent. */ - sections(): readonly PromptSection[]; - /** Wrapper failures of this state. */ - failures(): readonly RegistryFailure[]; - /** Conversation setups in registry order, the built-in `pi` setup first. */ - conversationSetups(): readonly { readonly key: string; readonly setup: ConversationSetup }[]; } /** Read side of a registry consumed by a Harness. */ @@ -286,60 +284,64 @@ export interface RegistryReader void): () => void; } -/** Application-owned registry of tools, hooks, tasks, and the system prompt. */ +/** Application-owned registry of extensions. */ export interface Registry extends RegistryReader { - readonly tools: { - add(tool: Tool): Registration; - /** `key` identifies the wrapper: it orders wrappers of one tool and keeps its position on re-registration. */ - wrap(name: string, key: string, wrapper: ToolWrapper): Registration; - /** Composed tools of the current state. */ - list(): readonly Tool[]; - }; - readonly hooks: { - add( - task: K, - handlers: Partial>, - options?: { readonly scope?: HookScope; readonly key?: string }, - ): Registration; - }; - readonly tasks: { - add(task: AnyTask): Registration; - list(): readonly AnyTask[]; - }; - readonly conversations: { - /** `key` orders setups and keeps its position on re-registration. */ - setup(key: string, setup: ConversationSetup): Registration; - }; - readonly systemPrompt: { - section(key: string, render: PromptSection["render"], options?: { readonly tag?: boolean }): Registration; - wrap(key: string, wrapperKey: string, wrapper: PromptSectionWrapper): Registration; - /** Composed sections of the current state. */ - sections(): readonly PromptSection[]; - }; - /** Stage registrations and disposals made synchronously by `register`, then publish them at once. Cannot nest. */ - batch(register: () => void): Registration; + /** Install `extension`, or replace the installed extension with its name in place. Publishes at once. */ + install(extension: Extension): void; + /** Remove the installed extension with `extension.name`, whichever object it is. A later install appends. */ + uninstall(extension: Extension): void; } -/** - * Stages the documents every new conversation gets. Runs inside every Harness commit that creates or forks a - * conversation, including raw `Tx` creation, after fork copies and before host `init`. Table reads throw, since the - * conversation write came first; a fork (`conversation.parent`) already holds its copied documents. A throw fails the - * creating commit. - */ -export type ConversationSetup = ( - tx: Tx, - conversation: ConversationRecord, - registry: RegistrySnapshot, -) => void | Promise; +/** Stored choices of one conversation; names, not objects. Unset fields follow the host. */ +export type AgentState = { + model?: ModelRef; + thinkingLevel?: ModelThinkingLevel; + /** An array selects exactly these extensions, in order. An object edits the host default selection. */ + extensions?: string[] | { add?: string[]; remove?: string[] }; + /** Filters the selected extensions' tools. An array offers exactly these, in order. */ + tools?: string[] | { remove: string[] }; + /** Rendered after every extension section, as the section `instructions`. */ + instructions?: string; + /** Directory within the environment's file system, passed to `HarnessOptions.env`. */ + cwd?: string; +}; + +/** A change to `pi.agent`: a given field replaces the stored one, `null` clears it, `undefined` changes nothing. */ +export type AgentChange = { + readonly model?: ModelRef | null; + readonly thinkingLevel?: ModelThinkingLevel | null; + readonly extensions?: + | readonly Extension[] + | { readonly add?: readonly Extension[]; readonly remove?: readonly Extension[] } + | null; + readonly tools?: readonly ToolRegistration[] | { readonly remove: readonly ToolRegistration[] } | null; + readonly instructions?: string | null; + readonly cwd?: string | null; +}; + +/** A conversation's agent resolved against a registry snapshot and the settings. */ +export type Agent = { + readonly model?: ModelRef; + readonly thinkingLevel: ModelThinkingLevel; + readonly extensions: readonly Extension[]; + /** The tools a request offers, in order. */ + readonly tools: readonly Tool[]; + /** Extension sections, then `instructions` when set. */ + readonly sections: readonly PromptSection[]; + readonly instructions?: string; + readonly cwd?: string; +}; /** - * Runs inside the creating commit, after the conversation and its configuration exist. The conversation creation is - * already a table write, so table reads here throw `ReadAfterWrite`; document access remains available. + * Runs inside the creating commit, after the creation hook and the `agent` change. The conversation creation is already + * a table write, so table reads here throw `ReadAfterWrite`; document access remains available. */ export type ConversationInit = (tx: Tx, conversationId: ConversationId) => void | Promise; export type ConversationCreateOptions = { readonly ownership: ConversationOwnership; + /** Applied in the creating commit after the creation hook's copy, before `init`. */ + readonly agent?: AgentChange; readonly init?: ConversationInit; }; @@ -385,12 +387,51 @@ export type CompactionReason = "manual" | "threshold" | "overflow"; */ export type CompactionResult = { entryId?: EntryId; submissionId?: SubmissionId }; +/** Harness-wide run policy. Read at every resolution and never copied; getters are fine. Synchronous: some readers run on the Session line. */ +export type HarnessSettings = { + /** Default extension selection; absent: every installed extension, in install order. */ + readonly extensions?: readonly Extension[]; + readonly stream?: ConversationStreamOptions; + readonly retry?: Partial; + readonly compaction?: Partial; + readonly toolExecution?: ToolExecutionMode; + readonly steeringMode?: QueueMode; + readonly followUpMode?: QueueMode; +}; + +/** Resolved settings: every field over its built-in default, object fields merged. */ +export type Settings = { + /** Absent: every installed extension, in install order. */ + readonly extensions?: readonly Extension[]; + readonly stream: ConversationStreamOptions; + readonly retry: ConversationRetryPolicy; + readonly compaction: CompactionPolicy; + readonly toolExecution: ToolExecutionMode; + readonly steeringMode: QueueMode; + readonly followUpMode: QueueMode; +}; + +/** What `HarnessOptions.env` builds an environment for. */ +export type EnvTarget = { + readonly conversationId: ConversationId; + /** The conversation's agent `cwd`. */ + readonly cwd?: string; + readonly read: DocumentReader; +}; + export type HarnessOptions = { /** pi-ai model access used by generation. */ readonly models: Models; readonly registry: RegistryReader; - /** Default execution environment offered to tools as `api.env`. */ - readonly env?: ExecutionEnv; + readonly settings?: HarnessSettings; + /** Builds a conversation's environment at each use. Never called on the Session line; may be async. */ + readonly env?: (target: EnvTarget, context: Context) => ExecutionEnv | undefined | Promise; + /** + * Runs in every commit that creates or forks a conversation, raw `tx.createConversation()` included, after the + * built-in `pi.*` documents and before the conveniences apply `agent` and run `init`. A fork already has its copies. + * Table reads throw `ReadAfterWrite`, as in `init`; a throw fails the creating commit. + */ + readonly conversationCreated?: (tx: Tx, conversation: ConversationRecord) => void | Promise; readonly now?: () => number; /** Receives extension failures that do not fail the calling operation. Must not throw. */ readonly onReport?: (error: unknown) => void; @@ -424,8 +465,6 @@ export type HarnessInspection = { readonly tasks: readonly TaskInspection[]; /** Queued and placed submissions, in ID order. */ readonly submissions: readonly SubmissionRecord[]; - /** Wrapper failures of the current registry snapshot. */ - readonly registry: readonly RegistryFailure[]; }; /** Raw active transcript and derived model context. */ @@ -444,35 +483,10 @@ export type ContextView = { export interface Conversation { readonly id: ConversationId; - getModel(context: Context): Promise; - setModel(model: ModelRef | undefined, context: Context): Promise; - getThinkingLevel(context: Context): Promise; - setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise; - getActiveTools(context: Context): Promise; - setActiveTools(names: readonly string[], context: Context): Promise; - /** `{}` when unset. */ - getStreamOptions(context: Context): Promise; - setStreamOptions(options: ConversationStreamOptions, context: Context): Promise; - /** The default policy when unset. */ - getRetryPolicy(context: Context): Promise; - /** `undefined` removes the configured policy. */ - setRetryPolicy(policy: ConversationRetryPolicy | undefined, context: Context): Promise; - /** `parallel` when unset. */ - getToolExecution(context: Context): Promise; - /** `undefined` removes the configured mode. */ - setToolExecution(mode: ToolExecutionMode | undefined, context: Context): Promise; - /** `one-at-a-time` when unset. */ - getSteeringMode(context: Context): Promise; - /** `undefined` removes the configured mode. */ - setSteeringMode(mode: QueueMode | undefined, context: Context): Promise; - /** `one-at-a-time` when unset. */ - getFollowUpMode(context: Context): Promise; - /** `undefined` removes the configured mode. */ - setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise; - /** `DEFAULT_COMPACTION_POLICY` when unset. */ - getCompaction(context: Context): Promise; - /** `undefined` removes the configured policy. */ - setCompaction(policy: CompactionPolicy | undefined, context: Context): Promise; + /** Resolved with the current registry snapshot and settings. */ + agent(context: Context): Promise; + /** `configure()` in its own commit. */ + configure(change: AgentChange, context: Context): Promise; /** * Durably admit user input or a passive entry write. A busy conversation, or one with queued items, queues it in @@ -527,13 +541,16 @@ export interface Harness extends Session { */ resume(): void; - /** Return the reserved root conversation, creating it with `init` in one commit when absent. */ - root(context: Context, options?: { readonly init?: ConversationInit }): Promise; + /** Return the reserved root conversation, creating it with `agent` and `init` in one commit when absent. */ + root( + context: Context, + options?: { readonly agent?: AgentChange; readonly init?: ConversationInit }, + ): Promise; conversation(id: ConversationId, context: Context): Promise; createConversation(options: ConversationCreateOptions, context: Context): Promise; getTask(id: TaskId, context: Context): Promise | undefined>; - /** Live tasks, unsettled submissions, and registry failures. Writes nothing and runs no task code. */ + /** Live tasks and unsettled submissions. Writes nothing and runs no task code. */ inspect(context: Context): Promise; /** Reacquire a submission, for example after reopen. */ submission(id: SubmissionId, context: Context): Promise; diff --git a/packages/durable/src/harness/view.ts b/packages/durable/src/harness/view.ts index cc7fad26f..bb942e164 100644 --- a/packages/durable/src/harness/view.ts +++ b/packages/durable/src/harness/view.ts @@ -14,7 +14,7 @@ import type { Storage, WatchHandle, } from "../types.ts"; -import { ConversationConfig } from "./config.ts"; +import { AgentDoc } from "./agent.ts"; import { activeEntries, captureContextBounds } from "./context.ts"; import { InboxDoc } from "./inbox.ts"; import { LiveDoc } from "./live.ts"; @@ -26,7 +26,7 @@ export type ConversationView = { readonly conversation: ConversationRecord; /** Raw active entries, as `ContextView.entries`: the head marker, then the non-head entries from its head. */ readonly entries: readonly EntryRecord[]; - /** `pi.conversation.config`, `pi.live`, `pi.inbox`, and `pi.usage`, keyed by kind; absent documents are absent. */ + /** `pi.agent`, `pi.live`, `pi.inbox`, and `pi.usage`, keyed by kind; absent documents are absent. */ readonly docs: Readonly>; }; @@ -44,12 +44,7 @@ export type ViewObserver = { closeSession(): void; }; -const MOUNTED = [ - ConversationConfig, - LiveDoc, - InboxDoc, - UsageDoc, -] as unknown as readonly ConversationDocToken[]; +const MOUNTED = [AgentDoc, LiveDoc, InboxDoc, UsageDoc] as unknown as readonly ConversationDocToken[]; const MOUNTED_KINDS: ReadonlySet = new Set(MOUNTED.map((token) => token.definition.kind)); /** One conversation's mount: its current revision, the document incarnations it shows, and its observers. */ diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index aafaf309a..ccd22761d 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -9,13 +9,19 @@ export { UserEntry, } from "./entries.ts"; export { ConversationBusy, ReadAfterWrite, StorageRejected } from "./errors.ts"; +export { + AgentDoc, + configure, + DEFAULT_COMPACTION_POLICY, + DEFAULT_RETRY_POLICY, +} from "./harness/agent.ts"; export { type CompactionCheckpoint, type CompactionInput, CompactionTask, type SummaryRequest, } from "./harness/compaction.ts"; -export { ConversationConfig, type ConversationConfigState } from "./harness/config.ts"; +export { defineExtension, defineTool, hook, section, wrapSection, wrapTool } from "./harness/define.ts"; export { type AgentEvent, type AgentEventStream, @@ -35,6 +41,9 @@ export { type CompactionStatus, LiveDoc, type LiveState, type ToolSlot } from ". export { createRegistry } from "./harness/registry.ts"; export { ToolTask, type ToolTaskCheckpoint, type ToolTaskInput, type ToolTaskResult } from "./harness/tool.ts"; export type { + Agent, + AgentChange, + AgentState, AnyTask, CompactionHooks, CompactionPolicy, @@ -47,27 +56,26 @@ export type { ConversationHandle, ConversationInit, ConversationRetryPolicy, - ConversationSetup, ConversationStreamOptions, + EnvTarget, + Extension, GenerationHooks, HarnessInspection, HarnessOptions, + HarnessSettings, HookApi, HookRegistration, HookResult, - HookScope, HooksOf, InputSubmissionDraft, ModelRef, PromptInput, PromptSection, - PromptSectionWrapper, QueueMode, - Registration, Registry, - RegistryFailure, RegistryReader, RegistrySnapshot, + Settings, SettledSubmissionRecord, SettledTask, Submission, @@ -80,8 +88,8 @@ export type { ToolExecutionResult, ToolHooks, ToolRegistration, - ToolWrapper, UserInput, + Wrap, } from "./harness/types.ts"; export { UsageDoc, type UsageState } from "./harness/usage.ts"; export type { ConversationView } from "./harness/view.ts"; diff --git a/packages/durable/src/session/transaction.ts b/packages/durable/src/session/transaction.ts index e9e32b789..450abde1b 100644 --- a/packages/durable/src/session/transaction.ts +++ b/packages/durable/src/session/transaction.ts @@ -268,7 +268,7 @@ export class Transaction implements Tx { return this.#read("submission", () => this.#host.storage.submission(id, this.#context)); } - /** Internal: committed submission with a conversation-scoped request ID. */ + /** Committed submission with a conversation-scoped request ID. */ submissionByRequest(conversationId: ConversationId, requestId: string): Promise { return this.#read("submissionByRequest", () => this.#host.storage.submissionByRequest(conversationId, requestId, this.#context), @@ -423,7 +423,7 @@ export class Transaction implements Tx { }); } - /** Internal: create a submission record with a fresh ID. */ + /** Create a raw submission record with a fresh ID; no admission rules apply. */ createSubmission(create: SubmissionCreate): Promise { return this.#write(async () => { await this.#requireConversation(create.conversationId); diff --git a/packages/durable/src/tools/bash.ts b/packages/durable/src/tools/bash.ts index 8ef2c9e87..b38a5f4fe 100644 --- a/packages/durable/src/tools/bash.ts +++ b/packages/durable/src/tools/bash.ts @@ -1,5 +1,6 @@ import type { Context } from "@earendil-works/chord"; import { type Static, Type } from "typebox"; +import { defineTool } from "../harness/define.ts"; import type { ToolExecutionApi, ToolRegistration } from "../harness/types.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "../truncate.ts"; import { requireEnv } from "./env.ts"; @@ -43,14 +44,14 @@ function validateTimeout(timeout: number | undefined): void { * file whose path is reported as a diagnostic. A nonzero exit or timeout throws, which makes an error result that still * carries the output and diagnostics. */ -export function createBashTool(options?: BashToolOptions): ToolRegistration { - return { +export function createBashTool(options?: BashToolOptions): ToolRegistration { + return defineTool({ name: "bash", description: `Execute a bash command in the current working directory. Returns combined stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, parameters: bashSchema, outputLimits: { retain: "tail" }, async execute(args, api, context) { - const { command, timeout } = args as BashToolInput; + const { command, timeout } = args; validateTimeout(timeout); const env = requireEnv(api); const execution: BashExecution = { @@ -85,5 +86,5 @@ export function createBashTool(options?: BashToolOptions): ToolRegistration { if (result.value.exitCode !== 0) throw new Error(`Command exited with code ${result.value.exitCode}`); return {}; }, - }; + }); } diff --git a/packages/durable/src/tools/edit.ts b/packages/durable/src/tools/edit.ts index b62040219..807b6d568 100644 --- a/packages/durable/src/tools/edit.ts +++ b/packages/durable/src/tools/edit.ts @@ -1,6 +1,6 @@ -import type { JsonValue } from "@earendil-works/chord"; import { type Static, Type } from "typebox"; import type { FileError } from "../env/index.ts"; +import { defineTool } from "../harness/define.ts"; import type { ToolRegistration } from "../harness/types.ts"; import { applyEditsToNormalizedContent, @@ -46,8 +46,8 @@ function isSingleEditInput(value: unknown): value is SingleEditInput { * Repair shapes models commonly send: `edits` as a JSON string or as a single edit object, and a top-level * `oldText`/`newText` pair. Works on a copy; the call's arguments stay unchanged. */ -function prepareEditArguments(input: JsonValue): JsonValue { - if (!input || typeof input !== "object" || Array.isArray(input)) return input; +function prepareEditArguments(input: unknown): EditToolInput { + if (!input || typeof input !== "object" || Array.isArray(input)) return input as EditToolInput; const args: Record = { ...input }; if (typeof args.edits === "string") { try { @@ -63,18 +63,18 @@ function prepareEditArguments(input: JsonValue): JsonValue { } const legacy = args as LegacyEditToolInput; - if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") return args as JsonValue; + if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") return legacy; const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : []; edits.push({ oldText: legacy.oldText, newText: legacy.newText }); const { oldText: _oldText, newText: _newText, ...rest } = legacy; - return { ...rest, edits } as JsonValue; + return { ...rest, edits }; } -export interface EditToolDetails { +export type EditToolDetails = { diff: string; patch: string; firstChangedLine?: number; -} +}; function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } { if (!Array.isArray(input.edits) || input.edits.length === 0) { @@ -87,15 +87,15 @@ function editAccessError(path: string, error: FileError): Error { return new Error(`Could not edit file: ${path}. Error code: ${error.code}.`, { cause: error }); } -export function createEditTool(): ToolRegistration { - return { +export function createEditTool(): ToolRegistration { + return defineTool({ name: "edit", description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.", parameters: editSchema, prepareArguments: prepareEditArguments, async execute(args, api, context) { - const { path, edits } = validateEditInput(args as EditToolInput); + const { path, edits } = validateEditInput(args); const env = requireEnv(api); const absolutePath = await resolveToolPath(env, path, context); return withFileMutationQueue( @@ -134,11 +134,11 @@ export function createEditTool(): ToolRegistration { }; return { content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${path}.` }], - details: details as unknown as JsonValue, + details, }; }, context, ); }, - }; + }); } diff --git a/packages/durable/src/tools/file-mutation-queue.ts b/packages/durable/src/tools/file-mutation-queue.ts index 0e4e6c43e..e0ee93d3b 100644 --- a/packages/durable/src/tools/file-mutation-queue.ts +++ b/packages/durable/src/tools/file-mutation-queue.ts @@ -1,61 +1,55 @@ import type { Context } from "@earendil-works/chord"; import { type ExecutionEnv, getOrThrow } from "../env/index.ts"; -type MutationQueueState = { - queues: Map>; - registration: Promise; -}; +/** Tail of the mutation chain of each file, keyed by file system id and canonical path. */ +const queues = new Map>(); -const states = new WeakMap(); - -function getState(env: ExecutionEnv): MutationQueueState { - let state = states.get(env); - if (!state) { - state = { queues: new Map(), registration: Promise.resolve() }; - states.set(env, state); - } - return state; -} - -async function getMutationQueueKey(env: ExecutionEnv, path: string, context: Context): Promise { +async function mutationKey(env: ExecutionEnv, path: string, context: Context): Promise { const absolutePath = getOrThrow(await env.absolutePath(path, context)); - const canonicalPath = await env.canonicalPath(absolutePath, context); - if (canonicalPath.ok) return canonicalPath.value; - if (canonicalPath.error.code === "not_found" || canonicalPath.error.code === "not_supported") return absolutePath; - throw canonicalPath.error; + return `${env.id}\0${await canonical(env, absolutePath, context)}`; } -/** Serialize file mutations targeting the same environment and canonical path. */ +/** + * The canonical path; for a file that does not exist yet, its canonical parent joined with its name, so a `write` that + * creates a file and a later mutation of it share one key even under a symlinked directory. + */ +async function canonical(env: ExecutionEnv, absolutePath: string, context: Context): Promise { + const result = await env.canonicalPath(absolutePath, context); + if (result.ok) return result.value; + if (result.error.code === "not_supported") return absolutePath; + if (result.error.code !== "not_found") throw result.error; + // The file system splits the path, so a name may contain characters that are separators elsewhere. + const parent = getOrThrow(await env.joinPath([absolutePath, ".."], context)); + if (parent === absolutePath || !absolutePath.startsWith(parent)) return absolutePath; + const name = absolutePath.slice(parent.length + (/[/\\]$/.test(parent) ? 0 : 1)); + return getOrThrow(await env.joinPath([await canonical(env, parent, context), name], context)); +} + +/** + * Serialize `edit` and `write` mutations of one file within this process: same file system id and canonical path, + * whichever environment object the call got. Other files, and other file systems, never wait. Concurrent calls on one + * file run in the order their keys resolve. Not a lock against `bash` or other processes. + */ export async function withFileMutationQueue( env: ExecutionEnv, path: string, fn: () => Promise, context: Context, ): Promise { - const state = getState(env); - const registration = state.registration.then(async () => { - const key = await getMutationQueueKey(env, path, context); - const currentQueue = state.queues.get(key) ?? Promise.resolve(); - - let releaseNext = () => {}; - const nextQueue = new Promise((resolve) => { - releaseNext = resolve; - }); - const chainedQueue = currentQueue.then(() => nextQueue); - state.queues.set(key, chainedQueue); - return { key, currentQueue, chainedQueue, releaseNext }; + const key = await mutationKey(env, path, context); + // Take the slot without awaiting, so no other call can take it in between. + const previous = queues.get(key) ?? Promise.resolve(); + let release = (): void => {}; + const done = new Promise((resolve) => { + release = resolve; }); - state.registration = registration.then( - () => undefined, - () => undefined, - ); - - const { key, currentQueue, chainedQueue, releaseNext } = await registration; - await currentQueue; + const tail = previous.then(() => done); + queues.set(key, tail); + await previous; try { return await fn(); } finally { - releaseNext(); - if (state.queues.get(key) === chainedQueue) state.queues.delete(key); + release(); + if (queues.get(key) === tail) queues.delete(key); } } diff --git a/packages/durable/src/tools/index.ts b/packages/durable/src/tools/index.ts index 7f5df8b4a..40bc9769f 100644 --- a/packages/durable/src/tools/index.ts +++ b/packages/durable/src/tools/index.ts @@ -1,3 +1,9 @@ +import { defineExtension } from "../harness/define.ts"; +import { createBashTool } from "./bash.ts"; +import { createEditTool } from "./edit.ts"; +import { createReadTool } from "./read.ts"; +import { createWriteTool } from "./write.ts"; + export { type BashExecution, type BashPrepare, @@ -8,3 +14,9 @@ export { export { createEditTool, type EditToolDetails, type EditToolInput } from "./edit.ts"; export { createReadTool, type ReadToolDetails, type ReadToolInput } from "./read.ts"; export { createWriteTool, type WriteToolInput } from "./write.ts"; + +/** `read`, `write`, `edit`, and `bash`; nothing installs it automatically. */ +export const CodingTools = defineExtension({ + name: "coding-tools", + tools: [createReadTool(), createWriteTool(), createEditTool(), createBashTool()], +}); diff --git a/packages/durable/src/tools/read.ts b/packages/durable/src/tools/read.ts index 7253a6afa..1fd914a47 100644 --- a/packages/durable/src/tools/read.ts +++ b/packages/durable/src/tools/read.ts @@ -1,6 +1,6 @@ -import type { JsonValue } from "@earendil-works/chord"; import { type Static, Type } from "typebox"; import { getOrThrow } from "../env/index.ts"; +import { defineTool } from "../harness/define.ts"; import { characterEnd } from "../harness/output.ts"; import type { ToolDiagnostic, ToolRegistration } from "../harness/types.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "../truncate.ts"; @@ -16,19 +16,19 @@ const readSchema = Type.Object({ export type ReadToolInput = Static; -export interface ReadToolDetails { +export type ReadToolDetails = { /** How the shown text was cut; the text itself is the result content. */ truncation?: Omit; -} +}; /** Reads text files. Remarks about truncation and continuation are diagnostics; the content is only file text. */ -export function createReadTool(): ToolRegistration { - return { +export function createReadTool(): ToolRegistration { + return defineTool({ name: "read", description: `Read the contents of a text file. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, parameters: readSchema, async execute(args, api, context) { - const { path, offset, limit } = args as ReadToolInput; + const { path, offset, limit } = args; const env = requireEnv(api); const absolutePath = await resolveReadToolPath(env, path, context); const bytes = getOrThrow(await env.readBinaryFile(absolutePath, context)); @@ -103,9 +103,9 @@ export function createReadTool(): ToolRegistration { return { content: outputText === "" ? [] : [{ type: "text", text: outputText }], - ...(details === undefined ? {} : { details: details as unknown as JsonValue }), + ...(details === undefined ? {} : { details }), diagnostics, }; }, - }; + }); } diff --git a/packages/durable/src/tools/write.ts b/packages/durable/src/tools/write.ts index 0147d534d..c25103948 100644 --- a/packages/durable/src/tools/write.ts +++ b/packages/durable/src/tools/write.ts @@ -1,5 +1,6 @@ import { type Static, Type } from "typebox"; import { getOrThrow } from "../env/index.ts"; +import { defineTool } from "../harness/define.ts"; import type { ToolRegistration } from "../harness/types.ts"; import { requireEnv } from "./env.ts"; import { withFileMutationQueue } from "./file-mutation-queue.ts"; @@ -12,14 +13,14 @@ const writeSchema = Type.Object({ export type WriteToolInput = Static; -export function createWriteTool(): ToolRegistration { - return { +export function createWriteTool(): ToolRegistration { + return defineTool({ name: "write", description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", parameters: writeSchema, async execute(args, api, context) { - const { path, content } = args as WriteToolInput; + const { path, content } = args; const env = requireEnv(api); const absolutePath = await resolveToolPath(env, path, context); return withFileMutationQueue( @@ -34,5 +35,5 @@ export function createWriteTool(): ToolRegistration { context, ); }, - }; + }); } diff --git a/packages/durable/src/types.ts b/packages/durable/src/types.ts index fe9a0d28b..7c0afcc04 100644 --- a/packages/durable/src/types.ts +++ b/packages/durable/src/types.ts @@ -2,7 +2,14 @@ import type { AttachedReplicatedState, Context, Draft, JsonValue } from "@earend import type { Op } from "@earendil-works/chord/delta"; import type { Message, Models } from "@earendil-works/pi-ai"; import type { ExecutionEnv } from "./env/index.ts"; -import type { ContextView, ConversationHandle, RegistrySnapshot, SettledTask } from "./harness/types.ts"; +import type { + Agent, + ContextView, + ConversationHandle, + RegistrySnapshot, + Settings, + SettledTask, +} from "./harness/types.ts"; /** JSON object used as the root of every durable document. */ export type JsonObject = { [key: string]: JsonValue }; @@ -170,10 +177,14 @@ export interface TaskRuntime extends DocumentObserver readonly signal: AbortSignal; /** Registry snapshot of the current phase; refreshed at every phase boundary. */ readonly registry: RegistrySnapshot; + /** The task's conversation's agent, resolved at most once per phase, at first use, and fixed for the phase. */ + agent(context: Context): Promise; + /** `HarnessOptions.settings`, resolved at each access. */ + readonly settings: Settings; readonly models: Models; - /** `HarnessOptions.env`; tools receive it as `api.env`. */ - readonly env: ExecutionEnv | undefined; - /** Handlers registered for this task's name whose scope matches its conversation. */ + /** Calls `HarnessOptions.env` for the task's conversation; rejects with its error. */ + env(context: Context): Promise; + /** Handlers of this task's name from the extensions its conversation selects, in extension order. */ readonly hooks: HookRunner; /** @@ -749,6 +760,8 @@ export interface Tx { limit: number, cursor?: Cursor, ): Promise, Cursor>>; + /** Committed submission with a conversation-scoped request ID. */ + submissionByRequest(conversationId: ConversationId, requestId: string): Promise; /** Create a conversation with explicitly selected ownership. */ createConversation(options: { readonly ownership: ConversationOwnership }): Promise; @@ -771,6 +784,11 @@ export interface Tx { input: I, options: TaskOptions, ): Promise>; + /** + * Create a raw submission record with a fresh ID. No admission rules apply: no busy check, no inbox queueing, no + * placement. Use `Conversation.submit()` or a conversation handle unless the caller implements admission itself. + */ + createSubmission(create: SubmissionCreate): Promise; /** * Settle a queued or placed submission; only a placed input can be answered, and a settled submission stays * unchanged. Resolved against this transaction's latest record of the submission, so it works after table writes. diff --git a/packages/durable/test/chat-support.ts b/packages/durable/test/chat-support.ts index bba799511..5f72a7edb 100644 --- a/packages/durable/test/chat-support.ts +++ b/packages/durable/test/chat-support.ts @@ -10,12 +10,14 @@ import { } from "@earendil-works/pi-ai"; import { type Conversation, - ConversationConfig, createRegistry, type EntryRecord, Harness, + type HarnessOptions, + type HarnessSettings, type Registry, type Storage, + type ToolRegistration, } from "@earendil-works/pi-durable"; import type { ExecutionEnv } from "../src/env/index.ts"; import { context } from "./session-support.ts"; @@ -26,6 +28,8 @@ export type ChatSetup = { readonly models: Models; readonly registry: Registry; readonly reports: unknown[]; + /** Live Harness settings; tests assign fields to change them between decisions. */ + readonly settings: { -readonly [K in keyof HarnessSettings]: HarnessSettings[K] }; now: () => number; }; @@ -33,31 +37,31 @@ export function chatSetup(options: RegisterFauxProviderOptions = {}): ChatSetup const faux = fauxProvider(options); const models = createModels(); models.setProvider(faux.provider); - return { faux, models, registry: createRegistry(), reports: [], now: () => Date.now() }; + return { faux, models, registry: createRegistry(), reports: [], settings: {}, now: () => Date.now() }; } /** Open a Harness over `storage` and return its root, configured with the faux model on first creation. */ export async function openChat( storage: Storage, setup: ChatSetup, - options: { readonly env?: ExecutionEnv } = {}, + /** One environment for every conversation, or an `env` function. */ + options: { readonly env?: ExecutionEnv | NonNullable } = {}, ): Promise<{ readonly harness: Harness; readonly root: Conversation }> { const harness = await Harness.open( storage, { models: setup.models, registry: setup.registry, - ...(options.env === undefined ? {} : { env: options.env }), + settings: setup.settings, + ...(options.env === undefined + ? {} + : { env: typeof options.env === "function" ? options.env : () => options.env as ExecutionEnv }), now: () => setup.now(), onReport: (error) => setup.reports.push(error), }, context, ); - const root = await harness.root(context, { - init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" }; - }, - }); + const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); return { harness, root }; } @@ -102,3 +106,13 @@ export function unanswered(): { readonly step: FauxResponseStep; readonly reache }); return { step, reached }; } + +/** The installed tools with these names, as `configure()` takes them. */ +export function toolsNamed(setup: ChatSetup, ...names: string[]): ToolRegistration[] { + const installed = setup.registry.snapshot().tools(); + return names.map((name) => { + const found = installed.find(({ tool }) => tool.name === name); + if (found === undefined) throw new Error(`Tool ${name} is not installed`); + return found.tool; + }); +} diff --git a/packages/durable/test/examples/06-harness.ts b/packages/durable/test/examples/06-harness.ts index 22da9e0aa..ef672f153 100644 --- a/packages/durable/test/examples/06-harness.ts +++ b/packages/durable/test/examples/06-harness.ts @@ -1,32 +1,34 @@ -// Open a Harness with a registry. +// 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 { - ConversationConfig, + AgentDoc, createRegistry, defineDoc, + defineExtension, + defineTool, Harness, MemoryStorage, - type ToolRegistration, } from "../../src/index.ts"; const context = BACKGROUND_CONTEXT; // A Harness is a Session plus conversation handles and durable tasks. Extension -// code (tools, hooks, tasks, system prompt sections, conversation setups) lives -// in a registry the application owns. Nothing in the registry is saved; it is -// this process's code. -const registry = createRegistry(); -const read: ToolRegistration = { +// 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) => ({ content: [{ type: "text", text: `read ${JSON.stringify(args)}` }] }), -}; -registry.tools.add(read); + execute: async (args) => ({ content: [{ 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); @@ -41,15 +43,24 @@ const Notes = defineDoc<{ text: string }>({ }); // The root conversation always has ID 1. The first root() call creates it, -// its built-in documents, and whatever `init` writes, all in one commit. Later -// calls, including after a restart, return it and ignore `init`. +// 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"; - (await tx.doc(ConversationConfig, rootId)).thinkingLevel = "low"; }, }); console.log("root:", root.id, await harness.snapshot(Notes, root.id, context)); -console.log("root config:", await harness.snapshot(ConversationConfig, 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); diff --git a/packages/durable/test/examples/07-configuration.ts b/packages/durable/test/examples/07-configuration.ts index 37c33fcc3..0e760ed93 100644 --- a/packages/durable/test/examples/07-configuration.ts +++ b/packages/durable/test/examples/07-configuration.ts @@ -1,10 +1,20 @@ -// Conversation configuration: model, thinking level, and active tools. +// 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 { createRegistry, Harness, MemoryStorage, type ToolRegistration } from "../../src/index.ts"; +import { + AgentDoc, + createRegistry, + defineExtension, + defineTool, + Harness, + type HarnessSettings, + MemoryStorage, + section, + type ToolRegistration, +} from "../../src/index.ts"; const context = BACKGROUND_CONTEXT; @@ -12,49 +22,78 @@ const context = BACKGROUND_CONTEXT; type AppTool = ToolRegistration & { readonly snippet?: string }; function exampleTool(name: string, description: string): AppTool { - return { + const tool = defineTool({ name, description, parameters: Type.Object({ path: Type.String() }), - snippet: `Use ${name} for files.`, - execute: async (args) => ({ content: [{ type: "text", text: `${name} ${JSON.stringify(args)}` }] }), - }; + execute: async (args) => ({ content: [{ 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({ name: "files", tools: [read, write] }); +const Search = defineExtension({ name: "search", tools: [grep] }); +// Sections see the offered tools as app tools, so one can render their snippets. +const Snippets = defineExtension({ + name: "snippets", + sections: [section("tool_snippets", (input) => input.agent.tools.map((tool) => tool.snippet).join("\n"))], +}); + const registry = createRegistry(); -registry.tools.add(exampleTool("read", "Read a file")); -const writeRegistration = registry.tools.add(exampleTool("write", "Write a file")); -registry.tools.add(exampleTool("grep", "Search files")); -const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context); +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); -// Model, thinking level, and active tool names live in the built-in -// ConversationConfig document. New conversations start with every registered -// tool active. Each setter is one commit. -console.log("active tools:", await root.getActiveTools(context)); -await root.setModel({ provider: "anthropic", modelId: "claude-sonnet-4-5" }, context); -await root.setThinkingLevel("high", context); -await root.setActiveTools(["write", "read"], context); -console.log("snippet kept on the app tool:", registry.tools.list()[0]!.snippet); -console.log("model:", await root.getModel(context), "thinking:", await root.getThinkingLevel(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()); -// Adding a name that is not registered is rejected, and nothing is written. -await root.setActiveTools(["read", "find"], context).catch((error: Error) => console.log("rejected:", error.message)); - -// Names that were already active are never rechecked. After "write" is -// unregistered it stays in the configuration; requests just stop offering it -// until it is registered again. -writeRegistration.dispose(); -await root.setActiveTools(["write", "read", "grep"], context); -console.log("active tools without a registered write:", await root.getActiveTools(context)); -console.log( - "registered tools:", - registry.tools.list().map((entry) => entry.name), +// 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()); -// Request options and the durable retry policy are configuration too. -await root.setStreamOptions({ timeoutMs: 60_000 }, context); -console.log("stream options:", await root.getStreamOptions(context)); -console.log("retry policy:", await root.getRetryPolicy(context)); +// 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); diff --git a/packages/durable/test/examples/08-harness-conversations.ts b/packages/durable/test/examples/08-harness-conversations.ts index 6d2d9dcba..da07f3055 100644 --- a/packages/durable/test/examples/08-harness-conversations.ts +++ b/packages/durable/test/examples/08-harness-conversations.ts @@ -3,7 +3,7 @@ // 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 { ConversationConfig, createRegistry, defineEntry, Harness, MemoryStorage } from "../../src/index.ts"; +import { createRegistry, defineEntry, Harness, MemoryStorage } from "../../src/index.ts"; const context = BACKGROUND_CONTEXT; const harness = await Harness.open( @@ -12,7 +12,7 @@ const harness = await Harness.open( context, ); const root = await harness.root(context); -await root.setThinkingLevel("high", 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`. @@ -27,20 +27,16 @@ const hello = await root.commit( ); console.log("typed entry:", Message.is(hello), hello.data.from); -// createConversation() and fork() run `init` in the creating commit. A fork -// starts with the configuration the parent had at the fork entry. +// 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" }, - init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).thinkingLevel = "minimal"; - }, - }, + { ownership: { kind: "ownerless" }, agent: { thinkingLevel: "minimal" } }, context, ); const retry = await root.fork(hello.id, { ownership: { kind: "ownerless" } }, context); -console.log("helper thinking:", await helper.getThinkingLevel(context)); -console.log("fork thinking:", await retry.getThinkingLevel(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); diff --git a/packages/durable/test/examples/10-registry-reload.ts b/packages/durable/test/examples/10-registry-reload.ts index 7b7eb02a6..bf3bd4c30 100644 --- a/packages/durable/test/examples/10-registry-reload.ts +++ b/packages/durable/test/examples/10-registry-reload.ts @@ -1,33 +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 { createRegistry, type ToolRegistration } from "../../src/index.ts"; +import { createModels } from "@earendil-works/pi-ai/models"; +import { createRegistry, defineExtension, defineTool, Harness, MemoryStorage, wrapTool } from "../../src/index.ts"; -function exampleTool(name: string, description: string): ToolRegistration { - return { +const context = BACKGROUND_CONTEXT; + +function exampleTool(name: string, description: string) { + return defineTool({ name, description, parameters: Type.Object({ path: Type.String() }), - execute: async (args) => ({ content: [{ type: "text", text: `${name} ${JSON.stringify(args)}` }] }), - }; + execute: async (args) => ({ content: [{ type: "text", text: `${name} ${args.path}` }] }), + }); } +const read = exampleTool("read", "Read a file"); const registry = createRegistry(); -registry.tools.add(exampleTool("read", "Read a file")); -const grep = registry.tools.add(exampleTool("grep", "Search files")); +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}`); -// batch() publishes a replacement at once, so no snapshot ever sees the tool -// missing. Work that already started keeps using the snapshot it took. -registry.batch(() => { - grep.dispose(); - registry.tools.add(exampleTool("grep", "Search files, faster")); +// 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)` }))], }); -console.log( - "tools after reload:", - registry.tools.list().map((entry) => `${entry.name}: ${entry.description}`), -); +registry.install(Audit); +console.log("with audit:", await tools()); -// Wrappers decorate a tool without replacing it, and survive its reload. -registry.tools.wrap("read", "audit", (tool) => ({ ...tool, description: `${tool.description} (audited)` })); -console.log("wrapped read:", registry.snapshot().tool("read")?.description); +// 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); diff --git a/packages/durable/test/examples/11-conversation-setup.ts b/packages/durable/test/examples/11-conversation-setup.ts deleted file mode 100644 index f42e352e0..000000000 --- a/packages/durable/test/examples/11-conversation-setup.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Give every new conversation the application's own documents. -// Run from packages/durable: -// node --conditions=source --experimental-strip-types test/examples/11-conversation-setup.ts -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { createModels } from "@earendil-works/pi-ai/models"; -import { createRegistry, defineDoc, Harness, LiveDoc, MemoryStorage } from "../../src/index.ts"; - -const context = BACKGROUND_CONTEXT; - -// A coding agent keeps a profile per conversation. Readers and watchers want it -// to exist from the first moment, like the built-in documents. -const AgentProfile = defineDoc<{ role: "main" | "subagent"; cwd: string }>({ - kind: "example.agent-profile", - version: 1, - scope: "conversation", - history: "rewindable", - fork: "asOf", - initial: () => ({ role: "main", cwd: process.cwd() }), -}); - -// A conversation setup runs in every commit that creates or forks a -// conversation, including raw tx.createConversation() in a tool, after the -// built-in `pi` setup and before the host's `init`. A fork already has its -// copied documents, so this setup leaves them alone. -const registry = createRegistry(); -registry.conversations.setup("agent", async (tx, conversation) => { - if (conversation.parent === undefined) await tx.doc(AgentProfile, conversation.id); -}); - -const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context); -const root = await harness.root(context); -const subagent = await harness.createConversation( - { - ownership: { kind: "ownerless" }, - // `init` runs after every setup, so the profile already exists here. - init: async (tx, id) => { - (await tx.doc(AgentProfile, id)).role = "subagent"; - }, - }, - context, -); -console.log("root profile:", await harness.snapshot(AgentProfile, root.id, context)); -console.log("subagent profile:", await harness.snapshot(AgentProfile, subagent.id, context)); -// The built-in setup created pi.live, so a UI can watch it before the first turn. -console.log("root live state:", await harness.snapshot(LiveDoc, root.id, context)); - -await harness.close(context); diff --git a/packages/durable/test/examples/11-extension-state.ts b/packages/durable/test/examples/11-extension-state.ts new file mode 100644 index 000000000..3f41a3cac --- /dev/null +++ b/packages/durable/test/examples/11-extension-state.ts @@ -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 { content: [{ 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); diff --git a/packages/durable/test/examples/12-tasks.ts b/packages/durable/test/examples/12-tasks.ts index a09bf5a51..26865ca97 100644 --- a/packages/durable/test/examples/12-tasks.ts +++ b/packages/durable/test/examples/12-tasks.ts @@ -3,7 +3,7 @@ // 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, defineTask, Harness, MemoryStorage } from "../../src/index.ts"; +import { createRegistry, defineExtension, defineTask, Harness, MemoryStorage } from "../../src/index.ts"; const context = BACKGROUND_CONTEXT; @@ -44,10 +44,11 @@ const Payment = defineTask<{ amount: number }, PaymentState, { receipt: number } }, }); -// The Harness finds task code by name in the registry. Nothing runs until -// resume() or a call that waits for progress, such as waitForTask(). +// 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.tasks.add(Payment); +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 diff --git a/packages/durable/test/examples/13-recovery.ts b/packages/durable/test/examples/13-recovery.ts index bbdd870ac..2b3cc4116 100644 --- a/packages/durable/test/examples/13-recovery.ts +++ b/packages/durable/test/examples/13-recovery.ts @@ -6,7 +6,7 @@ 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, defineTask, Harness } from "../../src/index.ts"; +import { createRegistry, defineExtension, defineTask, Harness } from "../../src/index.ts"; import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts"; const context = BACKGROUND_CONTEXT; @@ -52,7 +52,7 @@ const Ticker = defineTask<{ to: number }, { phase: "tick"; n: number }, string>( }, }); const registry = createRegistry(); -registry.tasks.add(Ticker); +registry.install(defineExtension({ name: "ticker", tasks: [Ticker] })); const open = async () => Harness.open(await openNodeSqliteStorage(databasePath), { models: createModels(), registry }, context); diff --git a/packages/durable/test/examples/14-chat.ts b/packages/durable/test/examples/14-chat.ts index f1b3eef9b..5abc706f0 100644 --- a/packages/durable/test/examples/14-chat.ts +++ b/packages/durable/test/examples/14-chat.ts @@ -4,7 +4,7 @@ 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, ConversationConfig, createRegistry, Harness, MemoryStorage } from "../../src/index.ts"; +import { AssistantEntry, createRegistry, defineExtension, Harness, MemoryStorage, section } from "../../src/index.ts"; const context = BACKGROUND_CONTEXT; @@ -15,16 +15,17 @@ models.setProvider(faux.provider); faux.setResponses([fauxAssistantMessage("Paris.")]); const registry = createRegistry(); -registry.systemPrompt.section("preamble", () => "You answer in one word.", { tag: false }); +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, { - init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" }; - }, -}); +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 registry's sections, +// 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); diff --git a/packages/durable/test/examples/15-system-prompt.ts b/packages/durable/test/examples/15-system-prompt.ts index 4451909cd..2202871c9 100644 --- a/packages/durable/test/examples/15-system-prompt.ts +++ b/packages/durable/test/examples/15-system-prompt.ts @@ -1,18 +1,19 @@ -// System prompt sections. +// 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, - ConversationConfig, - type ConversationInit, createRegistry, - defineDoc, + defineExtension, Harness, MemoryStorage, SystemEntry, + section, + wrapSection, } from "../../src/index.ts"; const context = BACKGROUND_CONTEXT; @@ -21,49 +22,54 @@ 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 registry's -// sections render the desired prompt, and only the difference to what the -// model already saw is appended to the transcript as a `pi.system` entry. -// Sections read per-conversation data through `input.read`; here a coding -// agent keeps its own profile document. -const AgentProfile = defineDoc<{ role: "main" | "subagent"; cwd: string }>({ - kind: "example.agent-profile", - version: 1, - scope: "conversation", - history: "rewindable", - fork: "asOf", - initial: () => ({ role: "main", cwd: "/repo" }), +// 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 .... + 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(); -// `tag: false` sends the text as is; by default it is wrapped in .... -registry.systemPrompt.section("preamble", () => "You are a coding agent.", { tag: false }); -registry.systemPrompt.section("cwd", async (input, renderContext) => { - return (await input.read.snapshot(AgentProfile, input.conversationId, renderContext))?.cwd; -}); -// Returning undefined omits the section, here for subagents. -registry.systemPrompt.section("agents_md", async (input, renderContext) => { - const profile = await input.read.snapshot(AgentProfile, input.conversationId, renderContext); - return profile?.role === "subagent" ? undefined : "Run npm run check after changes."; -}); -// Another extension decorates a section without replacing it. -registry.systemPrompt.wrap("preamble", "tone", (section) => ({ - ...section, - render: async (input, renderContext) => `${await section.render(input, renderContext)} Be terse.`, -})); +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" } }); -const harness = await Harness.open(new MemoryStorage(), { models, registry }, context); -const withModel: ConversationInit = async (tx, id) => { - (await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" }; - await tx.doc(AgentProfile, id); -}; -const root = await harness.root(context, { init: withModel }); +// A subagent deselects AGENTS.md and gets its own instructions, rendered last +// as the `instructions` section. const subagent = await harness.createConversation( { ownership: { kind: "ownerless" }, - init: async (tx, id) => { - await withModel(tx, id); - (await tx.doc(AgentProfile, id)).role = "subagent"; + agent: { + model, + cwd: "/repo", + extensions: { remove: [AgentsMd] }, + instructions: "Only read; never edit files.", }, }, context, @@ -80,9 +86,7 @@ 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.commit(async (tx) => { - (await tx.doc(AgentProfile, root.id)).cwd = "/repo/packages"; -}, context); +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)); diff --git a/packages/durable/test/examples/16-real-model.ts b/packages/durable/test/examples/16-real-model.ts index d6d66303e..00ce62856 100644 --- a/packages/durable/test/examples/16-real-model.ts +++ b/packages/durable/test/examples/16-real-model.ts @@ -4,7 +4,15 @@ 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, Harness, LiveDoc, MemoryStorage } from "../../src/index.ts"; +import { + AssistantEntry, + createRegistry, + defineExtension, + Harness, + LiveDoc, + MemoryStorage, + section, +} from "../../src/index.ts"; const context = BACKGROUND_CONTEXT; @@ -19,11 +27,16 @@ if (process.env.OPENAI_API_KEY === undefined) { const models = createModels(); models.setProvider(openaiProvider()); const registry = createRegistry(); - registry.systemPrompt.section("preamble", () => "You are a concise assistant.", { tag: false }); - const harness = await Harness.open(new MemoryStorage(), { models: models, registry: registry }, context); - const root = await harness.root(context); - await root.setModel({ provider: "openai", modelId: "gpt-6-sol" }, context); - await root.setThinkingLevel("high", context); + 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 = ""; diff --git a/packages/durable/test/examples/17-coding-tools.ts b/packages/durable/test/examples/17-coding-tools.ts index 21feb4f30..033313ff8 100644 --- a/packages/durable/test/examples/17-coding-tools.ts +++ b/packages/durable/test/examples/17-coding-tools.ts @@ -13,14 +13,15 @@ import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@ear import { NodeExecutionEnv } from "../../src/env/node.ts"; import { AssistantEntry, - ConversationConfig, createRegistry, + defineExtension, Harness, + hook, ToolResultEntry, ToolTask, } from "../../src/index.ts"; import { openNodeJsonlStorage } from "../../src/storage/jsonl/node.ts"; -import { type BashToolInput, createBashTool, createEditTool, createReadTool } from "../../src/tools/index.ts"; +import { type BashToolInput, CodingTools } from "../../src/tools/index.ts"; const context = BACKGROUND_CONTEXT; const directory = await mkdtemp(join(tmpdir(), "pi-durable-example-")); @@ -40,37 +41,39 @@ faux.setResponses([ fauxAssistantMessage([fauxText("The file now greets durable.")]), ]); -// Tools come from the registry and reach files and processes only through the environment the Harness offers them. -const registry = createRegistry(); -registry.batch(() => { - registry.tools.add(createReadTool()); - registry.tools.add(createEditTool()); - registry.tools.add(createBashTool()); -}); - // 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; -registry.hooks.add(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 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); -const env = new NodeExecutionEnv({ cwd: directory }); +// 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 }, context); -const root = await harness.root(context, { - init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" }; - }, -}); +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); diff --git a/packages/durable/test/examples/18-print.ts b/packages/durable/test/examples/18-print.ts index ed0be8b9a..2438efbb8 100644 --- a/packages/durable/test/examples/18-print.ts +++ b/packages/durable/test/examples/18-print.ts @@ -8,7 +8,7 @@ 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, Harness, MemoryStorage } from "../../src/index.ts"; +import { AssistantEntry, createRegistry, defineExtension, Harness, MemoryStorage, section } from "../../src/index.ts"; import { createBashTool, createReadTool } from "../../src/tools/index.ts"; const context = BACKGROUND_CONTEXT; @@ -29,13 +29,16 @@ if (process.env.OPENAI_API_KEY !== undefined) { } const registry = createRegistry(); -registry.systemPrompt.section("preamble", () => "You are a concise coding assistant.", { tag: false }); -registry.tools.add(createReadTool()); -registry.tools.add(createBashTool()); +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 }, context); -const root = await harness.root(context); -await root.setModel(model, context); +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); diff --git a/packages/durable/test/examples/19-json.ts b/packages/durable/test/examples/19-json.ts index 7addf76d2..95a894f1d 100644 --- a/packages/durable/test/examples/19-json.ts +++ b/packages/durable/test/examples/19-json.ts @@ -14,7 +14,15 @@ 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, Harness, MemoryStorage, type Storage, watchEvents } from "../../src/index.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"; @@ -58,13 +66,16 @@ if (process.env.OPENAI_API_KEY !== undefined) { } const registry = createRegistry(); -registry.systemPrompt.section("preamble", () => "You are a concise coding assistant.", { tag: false }); -registry.tools.add(createReadTool()); -registry.tools.add(createBashTool()); +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 }, context); -const root = await harness.root(context); -await root.setModel(model, context); +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; diff --git a/packages/durable/test/examples/20-inbox.ts b/packages/durable/test/examples/20-inbox.ts index 8ee01d4a6..90beb1ae3 100644 --- a/packages/durable/test/examples/20-inbox.ts +++ b/packages/durable/test/examples/20-inbox.ts @@ -23,11 +23,13 @@ faux.setResponses([slow, fauxAssistantMessage("Answer to the follow-up and the s const models = createModels(); models.setProvider(faux.provider); -const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context); -const root = await harness.root(context); -await root.setModel({ provider: "faux", modelId: "faux-1" }, context); -// Place every queued follow-up at once instead of one per run. -await root.setFollowUpMode("all", context); +// 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); diff --git a/packages/durable/test/examples/21-late-join.ts b/packages/durable/test/examples/21-late-join.ts index 209caa800..da65dd01b 100644 --- a/packages/durable/test/examples/21-late-join.ts +++ b/packages/durable/test/examples/21-late-join.ts @@ -9,6 +9,8 @@ import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@ear import { type AgentEvent, createRegistry, + defineExtension, + defineTool, Harness, type LiveState, MemoryStorage, @@ -20,18 +22,25 @@ 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.tools.add({ - 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 {}; - }, -}); +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" }), @@ -40,8 +49,7 @@ faux.setResponses([ const models = createModels(); models.setProvider(faux.provider); const harness = await Harness.open(new MemoryStorage(), { models, registry }, context); -const root = await harness.root(context); -await root.setModel({ provider: "faux", modelId: "faux-1" }, 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. diff --git a/packages/durable/test/examples/22-subagent-foreground.ts b/packages/durable/test/examples/22-subagent-foreground.ts index 0ad412405..060ded2db 100644 --- a/packages/durable/test/examples/22-subagent-foreground.ts +++ b/packages/durable/test/examples/22-subagent-foreground.ts @@ -4,6 +4,7 @@ // 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"; @@ -12,54 +13,68 @@ import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; import { type AgentEvent, AssistantEntry, - ConversationConfig, type ConversationId, + configure, createRegistry, + defineExtension, + defineTool, + type EntryId, + type Extension, Harness, MemoryStorage, - type ToolRegistration, + type ToolExecutionApi, watchEvents, } from "../../src/index.ts"; const context = BACKGROUND_CONTEXT; -// ─── Product code: the subagent tool ──────────────────────────────────────── +// ─── Product code: the subagent extension ─────────────────────────────────── -const subagent: ToolRegistration = { +async function answerText(api: ToolExecutionApi, answer: EntryId, callContext: Context): Promise { + 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", - 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 as { task: string }; - // 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. It starts with the parent's model and every tool except this one. - const parent = await api.snapshot(ConversationConfig, api.conversationId, callContext); - 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; - const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - const config = await tx.doc(ConversationConfig, created.id); - if (parent?.model !== undefined) config.model = { ...parent.model }; - config.activeTools = config.activeTools.filter((name) => name !== "subagent"); - return created.id; - }, callContext); - // A UI watching the parent sees this and can attach to the child. - await api.details({ conversationId: child }, callContext); + 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 conversation = (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 submission = await conversation.submit(request, callContext); - const settled = await submission.wait(callContext); - if (settled.status !== "done" || settled.type !== "input") throw new Error(`Subagent failed: ${settled.status}`); - const answer = await api.commit((tx) => tx.entry(AssistantEntry, settled.answer), callContext); - const text = (answer?.model?.[0] as AssistantMessage).content.flatMap((c) => (c.type === "text" ? [c.text] : [])); - return { content: [{ type: "text", text: text.join("") }], details: { conversationId: child } }; - }, -}; + 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 { content: [{ type: "text", text }], details: { conversationId: child } }; + }, + }), + ], +}); // ─── Host setup ───────────────────────────────────────────────────────────── @@ -80,10 +95,9 @@ if (process.env.OPENAI_API_KEY !== undefined) { ] satisfies FauxResponseStep[]); } const registry = createRegistry(); -registry.tools.add(subagent); +registry.install(Subagent); const harness = await Harness.open(new MemoryStorage(), { models, registry }, context); -const root = await harness.root(context); -await root.setModel(model, context); +const root = await harness.root(context, { agent: { model } }); // ─── UI: the parent's events, with each subagent's events indented under its call ─── diff --git a/packages/durable/test/examples/23-subagent-background.ts b/packages/durable/test/examples/23-subagent-background.ts index cdf9e5b5c..261a4e7e4 100644 --- a/packages/durable/test/examples/23-subagent-background.ts +++ b/packages/durable/test/examples/23-subagent-background.ts @@ -16,16 +16,17 @@ import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; import { type AgentEvent, AssistantEntry, - ConversationConfig, type ConversationId, + configure, createRegistry, defineDoc, + defineExtension, defineTask, + defineTool, type EntryId, Harness, LiveDoc, type TaskId, - type ToolRegistration, watchEvents, } from "../../src/index.ts"; import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts"; @@ -128,7 +129,7 @@ function textOf(message: AssistantMessage | undefined): string { return (message?.content ?? []).flatMap((c) => (c.type === "text" ? [c.text] : [])).join(""); } -const subagentTool: ToolRegistration = { +const subagentTool = defineTool({ name: "subagent", description: "Manage persistent subagents that work in the background. Actions: spawn (name, message), send (name, message; " + @@ -144,12 +145,7 @@ const subagentTool: ToolRegistration = { // was interrupted and can check with `status`. replay: "unsafe", execute: async (args, api, callContext) => { - const { action, name, message, followUp } = args as { - action: string; - name?: string; - message?: string; - followUp?: boolean; - }; + 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. @@ -184,7 +180,6 @@ const subagentTool: ToolRegistration = { if (message === undefined) return reply(`${action} needs a message.`); // spawn and send: one commit starts a reporter for the message. - const parentConfig = await api.snapshot(ConversationConfig, api.conversationId, callContext); 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. @@ -192,11 +187,13 @@ const subagentTool: ToolRegistration = { 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 } }); - // The subagent uses the main agent's model and every tool but this one. - const config = await tx.doc(ConversationConfig, child.id); - if (parentConfig?.model !== undefined) config.model = { ...parentConfig.model }; - config.activeTools = config.activeTools.filter((tool) => tool !== "subagent"); + // 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; @@ -207,7 +204,11 @@ const subagentTool: ToolRegistration = { 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 ───────────────────────────────────────────────────────────── @@ -253,9 +254,7 @@ if (process.env.OPENAI_API_KEY !== undefined) { faux.setResponses(Array.from({ length: 40 }, () => route)); } const registry = createRegistry(); -registry.tasks.add(Anchor); -registry.tasks.add(Reporter); -registry.tools.add(subagentTool); +registry.install(SubagentTools); const directory = await mkdtemp(join(tmpdir(), "pi-durable-subagents-")); const open = async () => { const harness = await Harness.open( @@ -263,8 +262,7 @@ const open = async () => { { models, registry }, context, ); - const root = await harness.root(context); - await root.setModel(model, context); + const root = await harness.root(context, { agent: { model } }); return { harness, root }; }; diff --git a/packages/durable/test/examples/24-child-tasks.ts b/packages/durable/test/examples/24-child-tasks.ts index bb6211e8d..887e2f50d 100644 --- a/packages/durable/test/examples/24-child-tasks.ts +++ b/packages/durable/test/examples/24-child-tasks.ts @@ -6,7 +6,14 @@ 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, defineTask, Harness, type TaskId } from "../../src/index.ts"; +import { + type Conversation, + createRegistry, + defineExtension, + defineTask, + Harness, + type TaskId, +} from "../../src/index.ts"; import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts"; const context = BACKGROUND_CONTEXT; @@ -83,8 +90,7 @@ const Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({ }); const registry = createRegistry(); -registry.tasks.add(Payment); -registry.tasks.add(Checkout); +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 () => diff --git a/packages/durable/test/examples/25-compaction.ts b/packages/durable/test/examples/25-compaction.ts index b41e69f23..5fb353ce1 100644 --- a/packages/durable/test/examples/25-compaction.ts +++ b/packages/durable/test/examples/25-compaction.ts @@ -43,11 +43,16 @@ faux.setResponses(Array.from({ length: 100 }, () => respond)); const models = createModels(); models.setProvider(faux.provider); -const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry() }, context); -const root = await harness.root(context); -await root.setModel({ provider: "faux", modelId: "tiny" }, context); // Generation blocks to compact above 3000 - 1000 = 2000 tokens and starts a background compaction above 2000 - 800. -await root.setCompaction({ enabled: true, reserveTokens: 1000, keepRecentTokens: 400, backgroundTokens: 800 }, context); +// 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 { const submission = await root.submit({ type: "input", content: question }, context); @@ -89,7 +94,7 @@ 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. -await root.setCompaction({ enabled: true, reserveTokens: 1000, keepRecentTokens: 400, backgroundTokens: 0 }, context); +backgroundTokens = 0; await ask("What should we pack?"); overflowOnce = true; await ask("Summarize the plan for my partner"); diff --git a/packages/durable/test/examples/26-coding-agent.ts b/packages/durable/test/examples/26-coding-agent.ts new file mode 100644 index 000000000..d5b0e7c47 --- /dev/null +++ b/packages/durable/test/examples/26-coding-agent.ts @@ -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 }); diff --git a/packages/durable/test/examples/27-plan-mode.ts b/packages/durable/test/examples/27-plan-mode.ts new file mode 100644 index 000000000..05c621cd1 --- /dev/null +++ b/packages/durable/test/examples/27-plan-mode.ts @@ -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 { content: [{ 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 }); diff --git a/packages/durable/test/examples/28-reviewer.ts b/packages/durable/test/examples/28-reviewer.ts new file mode 100644 index 000000000..f219aa9e0 --- /dev/null +++ b/packages/durable/test/examples/28-reviewer.ts @@ -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 }); diff --git a/packages/durable/test/examples/29-sandbox-per-conversation.ts b/packages/durable/test/examples/29-sandbox-per-conversation.ts new file mode 100644 index 000000000..792e328cb --- /dev/null +++ b/packages/durable/test/examples/29-sandbox-per-conversation.ts @@ -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 }); diff --git a/packages/durable/test/examples/30-tool-override.ts b/packages/durable/test/examples/30-tool-override.ts new file mode 100644 index 000000000..bafdcaa3a --- /dev/null +++ b/packages/durable/test/examples/30-tool-override.ts @@ -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 { + 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, "")); +console.log("timed bash calls:", timings.length); + +await harness.close(context); +await rm(project, { recursive: true, force: true }); diff --git a/packages/durable/test/examples/31-reload-and-restart.ts b/packages/durable/test/examples/31-reload-and-restart.ts new file mode 100644 index 000000000..727c6039f --- /dev/null +++ b/packages/durable/test/examples/31-reload-and-restart.ts @@ -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 { content: [{ 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) => + 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((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 }); diff --git a/packages/durable/test/harness-compaction.test.ts b/packages/durable/test/harness-compaction.test.ts index 886dd0fb9..9fc6b289e 100644 --- a/packages/durable/test/harness-compaction.test.ts +++ b/packages/durable/test/harness-compaction.test.ts @@ -19,6 +19,7 @@ import { type ContextView, type Conversation, defineTask, + defineTool, type EntryRecord, type Harness, LiveDoc, @@ -32,7 +33,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { selectCut, serializeConversation } from "../src/harness/compaction.ts"; import { orderToolResults } from "../src/harness/context.ts"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; -import { allEntries, type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts"; +import { allEntries, type ChatSetup, chatSetup, openChat, textOf, toolsNamed, waitFor } from "./chat-support.ts"; +import { addHooks, addSection, addTask, addTool } from "./harness-support.ts"; import { ControlledStorage, context } from "./session-support.ts"; import { aborted, type Deferred, deferred } from "./task-support.ts"; @@ -135,10 +137,10 @@ async function open( setup = chatSetup({ models: [{ id: "faux-1", contextWindow: options.contextWindow ?? 100_000, maxTokens: 900 }] }), faux = script(setup), ): Promise { - setup.registry.systemPrompt.section("preamble", () => "You are helpful.", { tag: false }); + addSection(setup.registry, "preamble", () => "You are helpful.", { tag: false }); const { harness, root } = await openChat(options.storage ?? new MemoryStorage(), setup); - await root.setCompaction(options.policy ?? MANUAL, context); - await root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 1 }, context); + setup.settings.compaction = options.policy ?? MANUAL; + setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 1 }; harness.resume(); return { harness, root, setup, faux }; } @@ -421,13 +423,16 @@ describe("manual compaction", () => { it("places a queued summary at postTools and the run continues in the compacted context", async () => { const chat = await open(); - chat.setup.registry.tools.add({ - name: "wait", - description: "wait", - parameters: Type.Object({}), - execute: async () => ({ content: [{ type: "text", text: "waited" }] }), - }); - await chat.root.setActiveTools(["wait"], context); + addTool( + chat.setup.registry, + defineTool({ + name: "wait", + description: "wait", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "waited" }] }), + }), + ); + await chat.root.configure({ tools: toolsNamed(chat.setup, "wait") }, context); await history(chat); const gate = deferred(); const reached = deferred(); @@ -585,7 +590,7 @@ describe("manual compaction", () => { const a = await chat.root.compact(undefined, context); await reached.promise; // B: larger budget, earlier cut; placed first. - await chat.root.setCompaction({ ...MANUAL, keepRecentTokens: 350 }, context); + chat.setup.settings.compaction = { ...MANUAL, keepRecentTokens: 350 }; chat.faux.summaries.push(summary("B")); await result(chat, await chat.root.compact(undefined, context)); expect(userText((await chat.root.context(context)).messages[0])).toContain("B"); @@ -614,7 +619,7 @@ describe("manual compaction", () => { await reached.promise; const submissions = []; for (const [index, keep] of keeps.entries()) { - await chat.root.setCompaction({ ...MANUAL, keepRecentTokens: keep }, context); + chat.setup.settings.compaction = { ...MANUAL, keepRecentTokens: keep }; chat.faux.summaries.push(summary(`S${index}`)); const outcome = await result(chat, await chat.root.compact(undefined, context)); submissions.push(outcome.status === "completed" ? outcome.result.submissionId! : undefined); @@ -685,7 +690,7 @@ describe("manual compaction", () => { const setup = chatSetup(); const faux = script(setup); const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setCompaction({ ...MANUAL, keepRecentTokens: 10 }, context); + setup.settings.compaction = { ...MANUAL, keepRecentTokens: 10 }; const id = await root.compact(undefined, context); // getTask() only reads, so progress here comes from compact() itself. await waitFor(async () => (await harness.getTask(id, context))?.state.status === "terminal"); @@ -711,18 +716,18 @@ describe("compaction outcomes", () => { it("asks beforeCompact: the first decision wins, a throw is reported and skipped", async () => { const chat = await open(); const seen: unknown[] = []; - chat.setup.registry.hooks.add(CompactionTask, { + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => { throw new Error("hook broke"); }, }); - chat.setup.registry.hooks.add(CompactionTask, { + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: (compaction) => { seen.push(compaction); return { summary: "FROM HOOK" }; }, }); - chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => ({ decline: true }) }); + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }); await history(chat); const outcome = await result(chat, await chat.root.compact("why", context)); expect(outcome.status).toBe("completed"); @@ -749,7 +754,7 @@ describe("compaction outcomes", () => { it("completes without a summary when a hook declines", async () => { const chat = await open(); - chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => ({ decline: true }) }); + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }); await history(chat); expect(await result(chat, await chat.root.compact(undefined, context))).toEqual({ status: "completed", @@ -762,7 +767,7 @@ describe("compaction outcomes", () => { it("fails with no_model without a configured model", async () => { const chat = await open(); await history(chat); - await chat.root.setModel(undefined, context); + await chat.root.configure({ model: null }, context); const outcome = await result(chat, await chat.root.compact(undefined, context)); expect(outcome).toMatchObject({ status: "failed", error: { detail: { reason: "no_model" } } }); expect((await live(chat)).compactions).toBeUndefined(); @@ -781,7 +786,7 @@ describe("compaction outcomes", () => { it("retries a retryable error with the pinned request and counts every attempt once", async () => { const single = await open(); await history(single); - await single.root.setThinkingLevel("high", context); + await single.root.configure({ thinkingLevel: "high" }, context); single.faux.summaries.push(summary()); const once = await compactionInput(single, async () => result(single, await single.root.compact(undefined, context)), @@ -791,12 +796,12 @@ describe("compaction outcomes", () => { const chat = await open(); await history(chat); - await chat.root.setThinkingLevel("high", context); - await chat.root.setStreamOptions({ timeoutMs: 1234, deferred: true }, context); + await chat.root.configure({ thinkingLevel: "high" }, context); + chat.setup.settings.stream = { timeoutMs: 1234, deferred: true }; chat.faux.summaries.push(async () => { // Changed during the attempt: the retry still uses the pinned request. - await chat.root.setThinkingLevel("low", context); - await chat.root.setStreamOptions({ timeoutMs: 1 }, context); + await chat.root.configure({ thinkingLevel: "low" }, context); + chat.setup.settings.stream = { timeoutMs: 1 }; return failure("overloaded"); }); chat.faux.summaries.push(summary()); @@ -815,7 +820,7 @@ describe("compaction outcomes", () => { it("adds no usage when a hook declines or supplies the summary", async () => { for (const decision of [{ decline: true } as const, { summary: "HOOK" }]) { const chat = await open(); - chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => decision }); + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => decision }); await history(chat); expect( await compactionInput(chat, async () => result(chat, await chat.root.compact(undefined, context))), @@ -826,15 +831,18 @@ describe("compaction outcomes", () => { it("caps maxTokens at the model's output limit and sends no tools", async () => { const chat = await open(); - chat.setup.registry.tools.add({ - name: "read", - description: "read", - parameters: Type.Object({}), - execute: async () => ({ content: [] }), - }); - await chat.root.setActiveTools(["read"], context); + addTool( + chat.setup.registry, + defineTool({ + name: "read", + description: "read", + parameters: Type.Object({}), + execute: async () => ({ content: [] }), + }), + ); + await chat.root.configure({ tools: toolsNamed(chat.setup, "read") }, context); await history(chat); - await chat.root.setCompaction({ ...MANUAL, reserveTokens: 2000 }, context); + chat.setup.settings.compaction = { ...MANUAL, reserveTokens: 2000 }; chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); const request = chat.faux.summaryRequests[0]!; @@ -898,7 +906,7 @@ describe("background threshold compaction", () => { it("starts above the background threshold without blocking the run; idle waits and Esc ignore it", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); - await chat.root.setCompaction(BACKGROUND, context); + chat.setup.settings.compaction = BACKGROUND; const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); @@ -930,7 +938,7 @@ describe("background threshold compaction", () => { it(`does not start when ${name}`, async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); - await chat.root.setCompaction(policy, context); + chat.setup.settings.compaction = policy; await turn(chat, text("u4", 100), text("a4", 100)); expect(await compactionTasks(chat)).toEqual([]); expect(chat.faux.summaryRequests).toHaveLength(0); @@ -945,7 +953,7 @@ describe("background threshold compaction", () => { chat.faux.summaries.push(gated(deferred(), summary(), reached)); const manual = await chat.root.compact(undefined, context); await reached.promise; - await chat.root.setCompaction(BACKGROUND, context); + chat.setup.settings.compaction = BACKGROUND; await turn(chat, text("u4", 100), text("a4", 100)); expect((await compactionTasks(chat)).map((task) => task.id)).toEqual([manual]); await chat.harness.abortTask(manual, context); @@ -956,7 +964,7 @@ describe("background threshold compaction", () => { for (const stop of ["task", "conversation"] as const) { const chat = await open({ contextWindow: 2000 }); await history(chat); - await chat.root.setCompaction(BACKGROUND, context); + chat.setup.settings.compaction = BACKGROUND; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); await turn(chat, text("u4", 100), text("a4", 100)); @@ -975,9 +983,9 @@ describe("blocking threshold compaction", () => { it("waits for its compaction, which appends the summary before the request", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; // A new section: preparation has a system entry to append, but must not append it before the wait. - chat.setup.registry.systemPrompt.section("extra", () => "EXTRA"); + addSection(chat.setup.registry, "extra", () => "EXTRA"); const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary(), reached)); @@ -1014,7 +1022,7 @@ describe("blocking threshold compaction", () => { it("sends the request once, without a second compaction, when the kept part is still above the threshold", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction({ ...BLOCKING, keepRecentTokens: 700 }, context); + chat.setup.settings.compaction = { ...BLOCKING, keepRecentTokens: 700 }; chat.faux.summaries.push(summary()); await turn(chat, text("u4", 400), "a4"); expect(chat.faux.summaryRequests).toHaveLength(1); @@ -1025,14 +1033,14 @@ describe("blocking threshold compaction", () => { for (const [name, prepare] of [ [ "declines", - (chat: Chat) => chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => ({ decline: true }) }), + (chat: Chat) => addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }), ], ["fails", (chat: Chat) => chat.faux.summaries.push(failure("bad request"))], ] as const) { it(`sends the request anyway when its compaction ${name}`, async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; prepare(chat); await turn(chat, text("u4", 200), "a4"); expect((await kinds(chat.root)).includes("pi.compaction")).toBe(false); @@ -1044,7 +1052,7 @@ describe("blocking threshold compaction", () => { it("sends the request anyway when its compaction is aborted directly", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); chat.faux.agent.push(answer("a4")); @@ -1060,7 +1068,7 @@ describe("blocking threshold compaction", () => { it("is aborted with its generation by Esc, before the generation's abort handler", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); const events: AgentEvent[] = []; @@ -1084,7 +1092,7 @@ describe("blocking threshold compaction", () => { it("wins over a background compaction still in flight, which then settles stale", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); - await chat.root.setCompaction(BACKGROUND, context); + chat.setup.settings.compaction = BACKGROUND; const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary("BACKGROUND"), reached)); @@ -1111,7 +1119,7 @@ describe("overflow compaction", () => { it("compacts and retries with the same attempt, leaving the error out of the retry", async () => { const chat = await open(); await history(chat); - await chat.root.setCompaction(ENABLED, context); + chat.setup.settings.compaction = ENABLED; chat.faux.summaries.push(summary()); let attempt: number | undefined; chat.faux.agent.push(failure(OVERFLOW)); @@ -1140,7 +1148,7 @@ describe("overflow compaction", () => { it("fails a second overflow with its error entry", async () => { const chat = await open(); await history(chat); - await chat.root.setCompaction(ENABLED, context); + chat.setup.settings.compaction = ENABLED; chat.faux.summaries.push(summary()); chat.faux.agent.push(failure(OVERFLOW), failure(OVERFLOW)); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); @@ -1168,7 +1176,7 @@ describe("overflow compaction", () => { it("fails after a blocking threshold compaction in the same generation", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; chat.faux.summaries.push(summary()); chat.faux.agent.push(failure(OVERFLOW)); const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); @@ -1180,17 +1188,23 @@ describe("overflow compaction", () => { for (const [name, prepare, requests] of [ [ "declines", - (chat: Chat) => chat.setup.registry.hooks.add(CompactionTask, { beforeCompact: () => ({ decline: true }) }), + (chat: Chat) => addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => ({ decline: true }) }), 0, ], ["fails", (chat: Chat) => chat.faux.summaries.push(failure("bad request")), 1], // Classification finds no cut, so no compaction starts and the ordinary failure carries the text. - ["cannot cut", (chat: Chat) => chat.root.setCompaction({ ...ENABLED, keepRecentTokens: 100_000 }, context), 0], + [ + "cannot cut", + (chat: Chat) => { + chat.setup.settings.compaction = { ...ENABLED, keepRecentTokens: 100_000 }; + }, + 0, + ], ] as const) { it(`fails with the overflow text when compaction ${name}`, async () => { const chat = await open(); await turn(chat, text("u1", 100), text("a1", 100)); - await chat.root.setCompaction(ENABLED, context); + chat.setup.settings.compaction = ENABLED; await prepare(chat); chat.faux.agent.push(failure(OVERFLOW)); const input = await chat.root.submit({ type: "input", content: text("u4", 100) }, context); @@ -1213,22 +1227,25 @@ describe("compaction estimates and interactions", () => { const chat = await open({ contextWindow: 2000 }, setup); const toolGate = deferred(); const toolReached = deferred(); - chat.setup.registry.tools.add({ - name: "slow", - description: "slow", - parameters: Type.Object({}), - execute: async () => { - toolReached.resolve(); - await toolGate.promise; - return { content: [{ type: "text", text: text("result", 200) }] }; - }, - }); - await chat.root.setActiveTools(["slow"], context); + addTool( + chat.setup.registry, + defineTool({ + name: "slow", + description: "slow", + parameters: Type.Object({}), + execute: async () => { + toolReached.resolve(); + await toolGate.promise; + return { content: [{ type: "text", text: text("result", 200) }] }; + }, + }), + ); + await chat.root.configure({ tools: toolsNamed(chat.setup, "slow") }, context); await turn(chat, text("u1", 220), text("a1", 220)); await turn(chat, text("u2", 220), text("a2", 220)); await turn(chat, text("u3", 220), text("a3", 220)); // Background at 900, blocking at 1500: the tool call's usage plus its result would cross 1500. - await chat.root.setCompaction({ ...BACKGROUND, backgroundTokens: 600 }, context); + chat.setup.settings.compaction = { ...BACKGROUND, backgroundTokens: 600 }; // The request starts a background compaction; its tool call's usage measures the whole context. chat.faux.agent.push(fauxAssistantMessage([fauxToolCall("slow", {})], { stopReason: "toolUse" })); chat.faux.agent.push(answer("done")); @@ -1256,12 +1273,12 @@ describe("compaction estimates and interactions", () => { it("rebaselines the system prompt over kept system deltas", async () => { const chat = await open(); let mood = "cheerful"; - chat.setup.registry.systemPrompt.section("mood", () => mood, { tag: false }); + addSection(chat.setup.registry, "mood", () => mood, { tag: false }); await turn(chat, text("u1", 100), text("a1", 100)); await turn(chat, text("u2", 100), text("a2", 100)); mood = "terse"; await turn(chat, text("u3", 100), text("a3", 100)); - await chat.root.setCompaction({ ...MANUAL, keepRecentTokens: 250 }, context); + chat.setup.settings.compaction = { ...MANUAL, keepRecentTokens: 250 }; chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); // The kept range holds the delta for the terse mood; the next request has one complete baseline. @@ -1294,7 +1311,7 @@ describe("compaction estimates and interactions", () => { context, ); let messages: readonly Message[] = []; - chat.setup.registry.hooks.add(CompactionTask, { + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: (compaction) => { messages = compaction.messages; return undefined; @@ -1380,8 +1397,8 @@ describe("compaction estimates and interactions", () => { const chat = await open(); const childGate = deferred(); const Child = defineChildTask(childGate); - chat.setup.registry.tasks.add(Child); - chat.setup.registry.hooks.add(CompactionTask, { + addTask(chat.setup.registry, Child); + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: async (_compaction, api, hookContext) => { await chat.harness.commit(async (tx) => { await tx.createTask( @@ -1443,7 +1460,7 @@ describe("compaction events and live status", () => { it("reports start and end, and the retry backoff in a late joiner's snapshot", async () => { const chat = await open(); await history(chat); - await chat.root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 60_000 }, context); + chat.setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 60_000 }; const events: AgentEvent[] = []; const stream = await watchEvents(chat.harness, chat.root.id, context); stream.start(async (batch) => { @@ -1500,11 +1517,11 @@ describe("compaction recovery", () => { async function first(path: string) { const setup = chatSetup(); - setup.registry.systemPrompt.section("preamble", () => "You are helpful.", { tag: false }); + addSection(setup.registry, "preamble", () => "You are helpful.", { tag: false }); const faux = script(setup); const { harness, root } = await openChat(await openNodeSqliteStorage(path), setup); - await root.setCompaction(MANUAL, context); - await root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 1 }, context); + setup.settings.compaction = MANUAL; + setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 1 }; harness.resume(); const chat = { harness, root, setup, faux }; await history(chat); @@ -1532,7 +1549,7 @@ describe("compaction recovery", () => { it("fails an overflow run with its text when its compaction fails after reopen", async () => { const path = await sqlitePath(); let chat = await first(path); - await chat.root.setCompaction({ ...MANUAL, enabled: true }, context); + chat.setup.settings.compaction = { ...MANUAL, enabled: true }; const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); chat.faux.agent.push(failure(OVERFLOW)); @@ -1556,7 +1573,7 @@ describe("compaction recovery", () => { let chat = await first(path); let calls = 0; const reached = deferred(); - chat.setup.registry.hooks.add(CompactionTask, { + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: async (_compaction, _api, hookContext) => { calls++; if (calls === 1) { @@ -1605,7 +1622,7 @@ describe("compaction recovery", () => { let chat = await first(path); let now = Date.now(); chat.setup.now = () => now; - await chat.root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 60_000 }, context); + chat.setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 60_000 }; chat.faux.summaries.push(failure("overloaded")); const id = await chat.root.compact(undefined, context); await waitFor(async () => (await live(chat)).compactions?.[0]?.retry !== undefined); @@ -1628,7 +1645,7 @@ describe("compaction recovery", () => { const reached = deferred(); chat.faux.summaries.push(gated(deferred(), summary(), reached)); // The faux model window is 128k; this blocking threshold needs a small context window. - await chat.root.setCompaction({ ...BLOCKING, reserveTokens: 128_000 - 700 }, context); + chat.setup.settings.compaction = { ...BLOCKING, reserveTokens: 128_000 - 700 }; const input = await chat.root.submit({ type: "input", content: text("u4", 200) }, context); await reached.promise; await chat.harness.close(context); @@ -1720,7 +1737,7 @@ describe("compaction and the inbox", () => { it("keeps the full retry budget after an overflow compaction", async () => { const chat = await open(); await history(chat); - await chat.root.setCompaction({ ...MANUAL, enabled: true }, context); + chat.setup.settings.compaction = { ...MANUAL, enabled: true }; chat.faux.summaries.push(summary()); chat.faux.agent.push( failure("prompt is too long"), @@ -1769,9 +1786,9 @@ describe("compaction edge cases", () => { it("keeps the one-compaction limit through a retry backoff", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; let asked = 0; - chat.setup.registry.hooks.add(CompactionTask, { + addHooks(chat.setup.registry, CompactionTask, { beforeCompact: () => { asked++; return { decline: true }; @@ -1788,11 +1805,11 @@ describe("compaction edge cases", () => { it("does not start a background compaction when a manual one was admitted during preparation", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); - await chat.root.setCompaction(BACKGROUND, context); + chat.setup.settings.compaction = BACKGROUND; const rendering = deferred(); const release = deferred(); let hold = true; - chat.setup.registry.systemPrompt.section("slow", async () => { + addSection(chat.setup.registry, "slow", async () => { if (hold) { hold = false; rendering.resolve(); @@ -1820,7 +1837,7 @@ describe("compaction edge cases", () => { const chat = await open({ contextWindow: 2000 }); await history(chat); // Blocking at 1500, background at 200: the kept part stays above the background threshold. - await chat.root.setCompaction({ ...BACKGROUND, backgroundTokens: 1300, keepRecentTokens: 400 }, context); + chat.setup.settings.compaction = { ...BACKGROUND, backgroundTokens: 1300, keepRecentTokens: 400 }; chat.faux.summaries.push(summary()); await turn(chat, text("u4", 1000), "a4"); expect(chat.faux.summaryRequests).toHaveLength(1); @@ -1831,7 +1848,7 @@ describe("compaction edge cases", () => { it("sends the request anyway when its blocking compaction faults", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; const completeSimple = chat.setup.models.completeSimple.bind(chat.setup.models); chat.setup.models.completeSimple = () => { throw new Error("no credentials"); @@ -1847,7 +1864,7 @@ describe("compaction edge cases", () => { for (const how of ["abort", "overflow"] as const) { const chat = await open(); await history(chat); - await chat.root.setCompaction({ ...MANUAL, enabled: true }, context); + chat.setup.settings.compaction = { ...MANUAL, enabled: true }; const reached = deferred(); if (how === "abort") chat.faux.summaries.push(gated(deferred(), summary(), reached)); else chat.faux.summaries.push(failure("prompt is too long for the summary")); @@ -1871,7 +1888,7 @@ describe("compaction edge cases", () => { it("treats a length stop as an ordinary answer, not an overflow", async () => { const chat = await open(); await history(chat); - await chat.root.setCompaction({ ...MANUAL, enabled: true }, context); + chat.setup.settings.compaction = { ...MANUAL, enabled: true }; chat.faux.agent.push(fauxAssistantMessage("cut short", { stopReason: "length", errorMessage: OVERFLOW })); const input = await chat.root.submit({ type: "input", content: "go" }, context); expect((await input.wait(context)).status).toBe("done"); @@ -1900,14 +1917,14 @@ describe("compaction edge cases", () => { it("puts compaction_start last in the batch of the preparation commit", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); - await chat.root.setCompaction(BACKGROUND, context); + chat.setup.settings.compaction = BACKGROUND; const events: AgentEvent[][] = []; const stream = await watchEvents(chat.harness, chat.root.id, context); stream.start(async (batch) => { events.push([...batch]); }); // A new section makes the preparation commit append a system entry next to the compaction's status. - chat.setup.registry.systemPrompt.section("extra", () => "extra"); + addSection(chat.setup.registry, "extra", () => "extra"); chat.faux.summaries.push(gated(deferred(), summary())); await turn(chat, text("u4", 100), "a4"); await waitFor(() => events.some((batch) => batch.some((event) => event.type === "compaction_start"))); @@ -1921,8 +1938,8 @@ describe("compaction edge cases", () => { it("keeps a background summary queued through a retry backoff while a blocking compaction wins", async () => { const chat = await open({ contextWindow: 2000 }); await history(chat); - await chat.root.setCompaction(BACKGROUND, context); - await chat.root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 300 }, context); + chat.setup.settings.compaction = BACKGROUND; + chat.setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 300 }; const summaryGate = deferred(); const summaryReached = deferred(); chat.faux.summaries.push(gated(summaryGate, summary("BACKGROUND"), summaryReached)); @@ -1945,7 +1962,7 @@ describe("compaction edge cases", () => { context, ))!; expect((await submission.status(context)).status).toBe("queued"); - await chat.root.setCompaction({ ...BACKGROUND, reserveTokens: 1500, keepRecentTokens: 50 }, context); + chat.setup.settings.compaction = { ...BACKGROUND, reserveTokens: 1500, keepRecentTokens: 50 }; expect((await input.wait(context)).status).toBe("done"); expect(userText(chat.faux.agentRequests.at(-1)!.messages[0])).toContain("BLOCKING"); expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "stale" }); @@ -1994,7 +2011,7 @@ describe("blocking and manual compaction together", () => { /** A run whose generation waits on a blocking compaction whose summary is held; returns the input and gate. */ async function blockingRun(chat: Chat) { await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; const gate = deferred(); const reached = deferred(); chat.faux.summaries.push(gated(gate, summary("BLOCKING"), reached)); @@ -2107,7 +2124,7 @@ describe("compaction pinning, silent overflow, and late policy changes", () => { let attempt: number | undefined; chat.faux.summaries.push(async () => { // Switched during the attempt: the retry still uses the pinned model. - await chat.root.setModel({ provider: "faux", modelId: "faux-2" }, context); + await chat.root.configure({ model: { provider: "faux", modelId: "faux-2" } }, context); return failure("overloaded"); }); chat.faux.summaries.push(async () => { @@ -2144,10 +2161,12 @@ describe("compaction pinning, silent overflow, and late policy changes", () => { const chat = await open({ contextWindow: 300 }); await history(chat); // Thresholds out of reach, so only overflow classification could compact. - await chat.root.setCompaction( - { enabled: true, reserveTokens: -100_000, keepRecentTokens: 150, backgroundTokens: 0 }, - context, - ); + chat.setup.settings.compaction = { + enabled: true, + reserveTokens: -100_000, + keepRecentTokens: 150, + backgroundTokens: 0, + }; chat.faux.agent.push(response); const input = await chat.root.submit({ type: "input", content: "go" }, context); expect((await input.wait(context)).status).toBe("done"); @@ -2162,13 +2181,13 @@ describe("compaction pinning, silent overflow, and late policy changes", () => { it("sends the request when a blocking compaction finds nothing under a policy changed after preparation", async () => { const chat = await open({ contextWindow: 1000 }); await history(chat); - await chat.root.setCompaction(BLOCKING, context); + chat.setup.settings.compaction = BLOCKING; let changed = false; - chat.setup.registry.systemPrompt.section("policy", async () => { + addSection(chat.setup.registry, "policy", async () => { // Rendering runs after preparation read the policy; the compaction reads this one. if (!changed) { changed = true; - await chat.root.setCompaction({ ...BLOCKING, keepRecentTokens: 100_000 }, context); + chat.setup.settings.compaction = { ...BLOCKING, keepRecentTokens: 100_000 }; } return "p"; }); @@ -2243,7 +2262,7 @@ describe("context contributions", () => { expect(view.contributions.map((messages) => messages.map(userText))).toEqual([["H"], ["a"], [], ["c"]]); expect(view.messages.map(userText)).toEqual(["H", "a", "c"]); // The summarizer sees the same contributions: the omitted entry stays out. - await chat.root.setCompaction({ ...MANUAL, keepRecentTokens: 1 }, context); + chat.setup.settings.compaction = { ...MANUAL, keepRecentTokens: 1 }; chat.faux.summaries.push(summary()); await result(chat, await chat.root.compact(undefined, context)); const prompt = userText(chat.faux.summaryRequests[0]!.messages[1]); diff --git a/packages/durable/test/harness-conversations.test.ts b/packages/durable/test/harness-conversations.test.ts index 705c39902..a12306a05 100644 --- a/packages/durable/test/harness-conversations.test.ts +++ b/packages/durable/test/harness-conversations.test.ts @@ -1,24 +1,27 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createModels } from "@earendil-works/pi-ai"; import { + AgentDoc, type Conversation, - ConversationConfig, type ConversationId, + configure, createRegistry, createSession, defineDoc, defineEntry, + defineExtension, defineTask, type EntryRecord, + Harness, LiveDoc, MemoryStorage, ROOT_CONVERSATION_ID, - type TaskId, } from "@earendil-works/pi-durable"; import { afterEach, describe, expect, it } from "vitest"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; -import { openHarness, tool, user } from "./harness-support.ts"; +import { addTool, openHarness, tool, user } from "./harness-support.ts"; import { ControlledStorage, context } from "./session-support.ts"; const directories = new Set(); @@ -64,19 +67,21 @@ async function allEntries(conversation: Conversation): Promise { } describe("Harness root and conversations", () => { - it("creates the root lazily with its configuration and init in one commit", async () => { + it("creates the root lazily with its agent change and init in one commit", async () => { const storage = new ControlledStorage(); const { harness } = await openHarness(storage, ["read", "bash"]); expect(storage.commits).toHaveLength(0); const root = await harness.root(context, { + agent: { thinkingLevel: "high" }, init: async (tx, id) => { + // The agent change applied before init. + expect((await tx.doc(AgentDoc, id)).thinkingLevel).toBe("high"); (await tx.doc(NoteDoc, id)).text = "root note"; - (await tx.doc(ConversationConfig, id)).thinkingLevel = "high"; }, }); expect(root.id).toBe(ROOT_CONVERSATION_ID); expect(storage.commits).toHaveLength(1); - // Conversation, pi.live, pi.inbox, pi.usage, configuration, and the init note. + // Conversation, pi.live, pi.inbox, pi.usage, pi.agent, and the init note. expect(storage.commits[0]!.map((write) => write.type)).toEqual([ "conversation", "document.create", @@ -86,11 +91,14 @@ describe("Harness root and conversations", () => { "document.create", ]); expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({}); - expect(await root.getActiveTools(context)).toEqual(["read", "bash"]); - expect(await root.getThinkingLevel(context)).toBe("high"); + expect(await harness.snapshot(AgentDoc, root.id, context)).toEqual({ thinkingLevel: "high" }); + const agent = await root.agent(context); + expect(agent.thinkingLevel).toBe("high"); + expect(agent.tools.map((each) => each.name)).toEqual(["read", "bash"]); expect(await harness.snapshot(NoteDoc, root.id, context)).toEqual({ text: "root note" }); - expect((await harness.root(context, { init: () => expect.unreachable() })).id).toBe(root.id); + const again = await harness.root(context, { agent: { thinkingLevel: "low" }, init: () => expect.unreachable() }); + expect(again.id).toBe(root.id); expect(storage.commits).toHaveLength(1); await harness.close(context); }); @@ -99,23 +107,27 @@ describe("Harness root and conversations", () => { const path = await sqlitePath(); let first = await openHarness(await openNodeSqliteStorage(path), ["read"]); const root = await first.harness.root(context); - await root.setModel({ provider: "anthropic", modelId: "claude" }, context); + await root.configure({ model: { provider: "anthropic", modelId: "claude" }, cwd: "/repo" }, context); const entry = await append(root, "hello"); const child = await first.harness.createConversation({ ownership: { kind: "ownerless" } }, context); const fork = await root.fork(entry.id, { ownership: { kind: "ownerless" } }, context); await first.harness.close(context); - await expect(root.getModel(context)).rejects.toThrow(); + await expect(root.agent(context)).rejects.toThrow(); + // The new process installs nothing: the stored choices survive, the tools do not resolve. first = await openHarness(await openNodeSqliteStorage(path), []); const reopened = await first.harness.root(context, { init: () => expect.unreachable() }); expect(reopened.id).toBe(ROOT_CONVERSATION_ID); - expect(await reopened.getModel(context)).toEqual({ provider: "anthropic", modelId: "claude" }); - expect(await reopened.getActiveTools(context)).toEqual(["read"]); + expect(await reopened.agent(context)).toMatchObject({ + model: { provider: "anthropic", modelId: "claude" }, + cwd: "/repo", + tools: [], + }); expect(await allEntries(reopened)).toEqual(["hello"]); expect((await first.harness.conversation(child.id, context))?.id).toBe(child.id); const reopenedFork = await first.harness.conversation(fork.id, context); expect(await allEntries(reopenedFork!)).toEqual(["hello"]); - expect(await reopenedFork!.getModel(context)).toEqual({ provider: "anthropic", modelId: "claude" }); + expect((await reopenedFork!.agent(context)).model).toEqual({ provider: "anthropic", modelId: "claude" }); expect(await first.harness.conversation(999 as ConversationId, context)).toBeUndefined(); await first.harness.close(context); }); @@ -134,13 +146,13 @@ describe("Harness root and conversations", () => { ); expect(storage.commits).toHaveLength(1); expect(await allEntries(created)).toEqual(["seed"]); - expect(await created.getActiveTools(context)).toEqual(["read"]); const before = storage.commits.length; await expect( harness.createConversation( { ownership: { kind: "ownerless" }, + agent: { thinkingLevel: "high" }, init: () => { throw new Error("init failed"); }, @@ -148,57 +160,46 @@ describe("Harness root and conversations", () => { context, ), ).rejects.toThrow("init failed"); - await expect( - harness.createConversation( - { - ownership: { kind: "ownerless" }, - init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).activeTools.push("missing"); - }, - }, - context, - ), - ).rejects.toThrow("Tools are not registered: missing"); expect(storage.commits).toHaveLength(before); - registry.tools.add(tool("bash")); - const later = await harness.createConversation({ ownership: { kind: "ownerless" } }, context); - expect(await later.getActiveTools(context)).toEqual(["read", "bash"]); - expect(await created.getActiveTools(context)).toEqual(["read"]); + // A conversation on the default selection follows installs live. + addTool(registry, tool("bash")); + const names = async (conversation: Conversation) => + (await conversation.agent(context)).tools.map((each) => each.name); + expect(await names(created)).toEqual(["read", "bash"]); await harness.close(context); }); - it("forks at a concrete entry with as-of configuration and init overrides", async () => { + it("forks at a concrete entry with the as-of agent and applies agent and init overrides", async () => { const { harness, registry } = await openHarness(new MemoryStorage(), ["read"]); - const legacy = registry.tools.add(tool("legacy")); + const legacy = tool("legacy"); + addTool(registry, legacy); const root = await harness.root(context); - await root.setThinkingLevel("low", context); + await root.configure({ thinkingLevel: "low" }, context); const at = await append(root, "one"); - await root.setThinkingLevel("high", context); - await root.setActiveTools(["read"], context); + await root.configure({ thinkingLevel: "high", tools: [tool("read")] }, context); await append(root, "two"); - legacy.dispose(); const child = await root.fork(at.id, { ownership: { kind: "ownerless" } }, context); - expect(await child.getThinkingLevel(context)).toBe("low"); - expect(await child.getActiveTools(context)).toEqual(["read", "legacy"]); + expect(await harness.snapshot(AgentDoc, child.id, context)).toEqual({ thinkingLevel: "low" }); expect(await allEntries(child)).toEqual(["one"]); const overridden = await root.fork( at.id, { ownership: { kind: "ownerless" }, + agent: { thinkingLevel: "minimal", tools: [legacy] }, init: async (tx, id) => { - const config = await tx.doc(ConversationConfig, id); - config.thinkingLevel = "minimal"; - config.activeTools = ["legacy"]; + expect((await tx.doc(AgentDoc, id)).thinkingLevel).toBe("minimal"); }, }, context, ); - expect(await overridden.getThinkingLevel(context)).toBe("minimal"); - expect(await overridden.getActiveTools(context)).toEqual(["legacy"]); - expect(await root.getThinkingLevel(context)).toBe("high"); + expect(await harness.snapshot(AgentDoc, overridden.id, context)).toEqual({ + thinkingLevel: "minimal", + tools: ["legacy"], + }); + expect(await harness.snapshot(AgentDoc, root.id, context)).toEqual({ thinkingLevel: "high", tools: ["read"] }); const unrelated = await harness.createConversation({ ownership: { kind: "ownerless" } }, context); await expect(root.fork(at.id, { ownership: { kind: "ownerless" } }, context)).resolves.toBeDefined(); @@ -208,53 +209,6 @@ describe("Harness root and conversations", () => { await harness.close(context); }); - it("inherits unavailable active tools through forks without revalidating them", async () => { - const { harness, registry } = await openHarness(new MemoryStorage(), []); - const legacy = registry.tools.add(tool("legacy")); - registry.tools.add(tool("read")); - const root = await harness.root(context); - const at = await append(root, "one"); - legacy.dispose(); - const child = await root.fork( - at.id, - { - ownership: { kind: "ownerless" }, - init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).thinkingLevel = "low"; - }, - }, - context, - ); - expect(await child.getActiveTools(context)).toEqual(["legacy", "read"]); - await expect( - root.fork( - at.id, - { - ownership: { kind: "ownerless" }, - init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).activeTools = ["read", "gone"]; - }, - }, - context, - ), - ).rejects.toThrow("Tools are not registered: gone"); - - // Raw writes are trusted: an inherited duplicate forks with or without init. - await root.commit(async (tx) => { - (await tx.doc(ConversationConfig, root.id)).activeTools = ["read", "read"]; - }, context); - const duplicated = await append(root, "two"); - for (const init of [undefined, () => {}]) { - const copy = await root.fork( - duplicated.id, - { ownership: { kind: "ownerless" }, ...(init === undefined ? {} : { init }) }, - context, - ); - expect(await copy.getActiveTools(context)).toEqual(["read", "read"]); - } - await harness.close(context); - }); - it("paginates fork-aware history through deep ancestor caps and same-commit prefixes", async () => { const { harness } = await openHarness(new MemoryStorage()); const root = await harness.root(context); @@ -308,139 +262,135 @@ describe("Harness root and conversations", () => { ).rejects.toThrow("requires options.conversationId"); await harness.close(context); }); + + it("runs conversationCreated in every creating commit, after the built-ins and before agent and init", async () => { + const seen: string[] = []; + const harness = await Harness.open( + new MemoryStorage(), + { + models: createModels(), + registry: createRegistry(), + conversationCreated: async (tx, conversation) => { + const agent = await tx.doc(AgentDoc, conversation.id); + seen.push(`${conversation.id}:${conversation.parent === undefined ? "new" : "fork"}:${agent.cwd}`); + (await tx.doc(NoteDoc, conversation.id)).text ||= "created"; + if (agent.cwd === "/fail") throw new Error("no"); + }, + }, + context, + ); + const root = await harness.root(context, { + agent: { cwd: "/root" }, + init: async (tx, id) => void seen.push(`init:${(await tx.doc(NoteDoc, id)).text}`), + }); + // A raw creation in a commit, as in a tool, and a fork, which already has the asOf copies. + const raw = await harness.commit( + async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, + context, + ); + const entry = await append(root, "hello"); + const fork = await root.fork(entry.id, { ownership: { kind: "ownerless" } }, context); + expect(seen).toEqual([ + `${root.id}:new:undefined`, + "init:created", + `${raw}:new:undefined`, + `${fork.id}:fork:/root`, + ]); + expect(await harness.snapshot(NoteDoc, raw, context)).toEqual({ text: "created" }); + // A throw fails the creating commit. + await root.configure({ cwd: "/fail" }, context); + await expect(root.fork(entry.id, { ownership: { kind: "ownerless" } }, context)).resolves.toBeDefined(); + const failing = await append(root, "after"); + await expect(root.fork(failing.id, { ownership: { kind: "ownerless" } }, context)).rejects.toThrow("no"); + await harness.close(context); + }); }); -describe("Harness configuration", () => { - it("gets and sets model, thinking level, and active tools", async () => { - const storage = new ControlledStorage(); - const { harness } = await openHarness(storage, ["read", "bash", "edit"]); +describe("Harness agent", () => { + it("replaces whole fields, clears them with null, and leaves undefined fields alone", async () => { + const { harness, registry } = await openHarness(new MemoryStorage()); + const read = tool("read"); + const bash = tool("bash"); + const edit = tool("edit"); + registry.install(defineExtension({ name: "coding", tools: [read, bash, edit] })); const root = await harness.root(context); - expect(await root.getModel(context)).toBeUndefined(); - expect(await root.getThinkingLevel(context)).toBe("off"); + const stored = () => harness.snapshot(AgentDoc, root.id, context); + const offered = async () => (await root.agent(context)).tools.map((each) => each.name); + expect(await root.agent(context)).toMatchObject({ thinkingLevel: "off", tools: [read, bash, edit] }); + expect((await root.agent(context)).model).toBeUndefined(); - await root.setModel({ provider: "openai", modelId: "gpt" }, context); - expect(await root.getModel(context)).toEqual({ provider: "openai", modelId: "gpt" }); - await root.setModel(undefined, context); - expect(await root.getModel(context)).toBeUndefined(); + await root.configure({ model: { provider: "openai", modelId: "gpt" }, thinkingLevel: "medium" }, context); + await root.configure({ model: undefined, instructions: "Be terse." }, context); + expect(await stored()).toEqual({ + model: { provider: "openai", modelId: "gpt" }, + thinkingLevel: "medium", + instructions: "Be terse.", + }); + await root.configure({ model: null, instructions: null }, context); + expect(await stored()).toEqual({ thinkingLevel: "medium" }); - await root.setThinkingLevel("medium", context); - expect(await root.getThinkingLevel(context)).toBe("medium"); - - await root.setActiveTools(["edit", "read"], context); - expect(await root.getActiveTools(context)).toEqual(["edit", "read"]); - const commits = storage.commits.length; - await expect(root.setActiveTools(["read", "read"], context)).rejects.toThrow("more than once"); - await expect(root.setActiveTools(["read", "unknown"], context)).rejects.toThrow( - "Tools are not registered: unknown", - ); - expect(storage.commits).toHaveLength(commits); - expect(await root.getActiveTools(context)).toEqual(["edit", "read"]); + await root.configure({ tools: { remove: [edit] } }, context); + expect(await offered()).toEqual(["read", "bash"]); + // A new filter replaces the old one: edit is offered again. + await root.configure({ tools: { remove: [bash] } }, context); + expect(await offered()).toEqual(["read", "edit"]); + await root.configure({ tools: [edit, read] }, context); + expect(await offered()).toEqual(["edit", "read"]); + await root.configure({ tools: null }, context); + expect(await offered()).toEqual(["read", "bash", "edit"]); + // Names are stored without checking the registry. + await root.configure({ tools: [tool("missing"), read] }, context); + expect(await stored()).toMatchObject({ tools: ["missing", "read"] }); + expect(await offered()).toEqual(["read"]); await harness.close(context); }); - it("keeps stale unregistered names on later edits", async () => { - const { harness, registry } = await openHarness(new MemoryStorage(), ["read"]); - const legacy = registry.tools.add(tool("legacy")); - const root = await harness.root(context); - legacy.dispose(); - expect(await root.getActiveTools(context)).toEqual(["read", "legacy"]); - await root.setActiveTools(["legacy"], context); - await root.setThinkingLevel("low", context); - // Raw document writes are trusted and unchecked. - await root.commit(async (tx) => { - (await tx.doc(ConversationConfig, root.id)).activeTools.push("anything"); - }, context); - expect(await root.getActiveTools(context)).toEqual(["legacy", "anything"]); - await harness.close(context); - }); - - it("stages the built-in documents for conversations created or forked through Tx", async () => { + it("gives conversations created through Tx their documents: empty, an owner copy, or the fork's as-of copy", async () => { const { harness } = await openHarness(new MemoryStorage(), ["read"]); - const id = await harness.commit( - async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, - context, - ); - const created = (await harness.conversation(id, context))!; - expect(await created.getActiveTools(context)).toEqual(["read"]); - expect(await harness.snapshot(LiveDoc, id, context)).toEqual({}); - - await created.setActiveTools([], context); - const at = await created.commit( - async (tx) => (await tx.appendEntry(id, { kind: "message", model: [user("again")] })).id, - context, - ); - await created.commit(async (tx) => { - (await tx.doc(LiveDoc, id)).run = { taskId: 99 as TaskId, inputs: [] }; - }, context); - const forkId = await harness.commit( - async (tx) => (await tx.forkConversation(id, at, { ownership: { kind: "ownerless" } })).id, - context, - ); - const fork = (await harness.conversation(forkId, context))!; - // A fork copies the configuration at its fork entry, not the registry default, and starts with an empty pi.live. - expect(await fork.getActiveTools(context)).toEqual([]); - expect(await harness.snapshot(LiveDoc, forkId, context)).toEqual({}); - await harness.close(context); - }); - - it("runs registered conversation setups after the built-in one on every creation path", async () => { - const Agent = defineDoc<{ kind: string; forks: number }>({ - kind: "test.agent", - version: 1, - scope: "conversation", - history: "latest", - fork: "current", - initial: () => ({ kind: "main", forks: 0 }), - }); - const registry = createRegistry(); - const order: string[] = []; - registry.conversations.setup("agent", async (tx, conversation) => { - order.push(`agent:${conversation.id}`); - const agent = await tx.doc(Agent, conversation.id); - // A fork keeps its copied document; the setup only records the fork. - if (conversation.parent !== undefined) agent.forks++; - }); - expect( - registry - .snapshot() - .conversationSetups() - .map(({ key }) => key), - ).toEqual(["pi", "agent"]); - expect(() => registry.conversations.setup("agent", () => {})).toThrow("Setup agent is already registered"); - const { harness } = await openHarness(new MemoryStorage(), [], { registry }); const root = await harness.root(context, { - init: async (tx, id) => { - // Host init runs after every setup, so the agent document already exists. - expect(order).toEqual([`agent:${id}`]); - (await tx.doc(Agent, id)).kind = "root"; - }, + agent: { model: { provider: "faux", modelId: "m" }, instructions: "Main role.", cwd: "/repo" }, }); - const raw = await root.commit( - async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, + const owner = defineTask, { phase: "never" }, null>({ + name: "test.owner", + version: 1, + initial: () => ({ phase: "never" }), + phases: { never: async () => {} }, + abort: async () => {}, + }); + const ids = await root.commit(async (tx) => { + const taskId = await tx.createTask(owner, {}, { ownership: { kind: "conversation" } }); + const plain = await tx.createConversation({ ownership: { kind: "ownerless" } }); + const owned = await tx.createConversation({ ownership: { kind: "task", taskId } }); + // The copy exists when createConversation() returns, so a configure() in the same callback overrides it. + expect((await tx.doc(AgentDoc, owned.id)).cwd).toBe("/repo"); + await configure(tx, owned.id, { cwd: "/worktree" }); + return { taskId, plain: plain.id, owned: owned.id }; + }, context); + expect(await harness.snapshot(AgentDoc, ids.plain, context)).toEqual({}); + expect(await harness.snapshot(LiveDoc, ids.plain, context)).toEqual({}); + expect(await harness.snapshot(AgentDoc, ids.owned, context)).toEqual({ + model: { provider: "faux", modelId: "m" }, + instructions: "Main role.", + cwd: "/worktree", + }); + + // A later owner change does not reach the child. + await root.configure({ thinkingLevel: "high" }, context); + expect((await harness.snapshot(AgentDoc, ids.owned, context))?.thinkingLevel).toBeUndefined(); + + // A task-owned fork keeps its as-of copy of its fork parent, not its owner's agent. + const at = await root.commit(async (tx) => (await tx.appendEntry(ids.plain, { kind: "note" })).id, context); + const fork = await root.commit( + async (tx) => + (await tx.forkConversation(ids.plain, at, { ownership: { kind: "task", taskId: ids.taskId } })).id, context, ); - const at = await root.commit(async (tx) => (await tx.appendEntry(root.id, { kind: "note" })).id, context); - const fork = await root.fork(at, { ownership: { kind: "ownerless" } }, context); - expect(await harness.snapshot(Agent, root.id, context)).toEqual({ kind: "root", forks: 0 }); - expect(await harness.snapshot(Agent, raw, context)).toEqual({ kind: "main", forks: 0 }); - expect(await harness.snapshot(Agent, fork.id, context)).toEqual({ kind: "root", forks: 1 }); - expect(await harness.snapshot(LiveDoc, raw, context)).toEqual({}); - expect(order).toEqual([`agent:${root.id}`, `agent:${raw}`, `agent:${fork.id}`]); - - registry.conversations.setup("broken", () => { - throw new Error("setup failed"); - }); - const count = async () => (await harness.commit((tx) => tx.scanConversations({}, 100), context)).items.length; - const before = await count(); - await expect(harness.createConversation({ ownership: { kind: "ownerless" } }, context)).rejects.toThrow( - "setup failed", - ); - // The whole creating commit rolled back. - expect(await count()).toBe(before); + expect(await harness.snapshot(AgentDoc, fork, context)).toEqual({}); + expect(await harness.snapshot(LiveDoc, fork, context)).toEqual({}); await harness.close(context); }); - it("reads initial configuration for conversations a plain Session created", async () => { + it("reads an absent agent for conversations a plain Session created without writing", async () => { const storage = new ControlledStorage(); const id = await createSession(storage).commit( async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, @@ -449,13 +399,11 @@ describe("Harness configuration", () => { const { harness } = await openHarness(storage, ["read"]); const raw = await harness.conversation(id, context); expect(await harness.snapshot(LiveDoc, id, context)).toBeUndefined(); - expect(await raw!.getThinkingLevel(context)).toBe("off"); - expect(await raw!.getActiveTools(context)).toEqual([]); const commits = storage.commits.length; - expect(await raw!.getModel(context)).toBeUndefined(); + expect(await raw!.agent(context)).toMatchObject({ thinkingLevel: "off", tools: [{ name: "read" }] }); expect(storage.commits).toHaveLength(commits); - await raw!.setActiveTools(["read"], context); - expect(await raw!.getActiveTools(context)).toEqual(["read"]); + await raw!.configure({ thinkingLevel: "low" }, context); + expect(await harness.snapshot(AgentDoc, id, context)).toEqual({ thinkingLevel: "low" }); await harness.close(context); }); }); diff --git a/packages/durable/test/harness-events.test.ts b/packages/durable/test/harness-events.test.ts index e88826936..9c11d4e22 100644 --- a/packages/durable/test/harness-events.test.ts +++ b/packages/durable/test/harness-events.test.ts @@ -9,10 +9,11 @@ import { Type, } from "@earendil-works/pi-ai"; import { + AgentDoc, type AgentEvent, type AgentEventStream, type Conversation, - ConversationConfig, + defineTool, type Harness, LiveDoc, type LiveState, @@ -24,6 +25,7 @@ import { } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; import { type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts"; +import { addTool } from "./harness-support.ts"; import { context, documentChanges } from "./session-support.ts"; import { aborted, deferred } from "./task-support.ts"; @@ -152,18 +154,21 @@ describe("agent events", () => { it("reports tool start, output appends, and the result entry", async () => { const setup = chatSetup(); const gate = deferred(); - setup.registry.tools.add({ - name: "print", - description: "Prints", - parameters: Type.Object({ n: Type.Number() }), - execute: async (_args, api) => { - api.output("one\n"); - await new Promise((resolve) => setTimeout(resolve, 150)); - api.output("two\n"); - await gate.promise; - return {}; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "print", + description: "Prints", + parameters: Type.Object({ n: Type.Number() }), + execute: async (_args, api) => { + api.output("one\n"); + await new Promise((resolve) => setTimeout(resolve, 150)); + api.output("two\n"); + await gate.promise; + return {}; + }, + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("print", { n: 1 }, { id: "c1" })], { stopReason: "toolUse" }), fauxAssistantMessage([fauxText("done")]), @@ -215,7 +220,7 @@ describe("agent events", () => { const error = fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 Service Unavailable" }); setup.faux.setResponses([held, error, fauxAssistantMessage([fauxText("second")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setRetryPolicy({ enabled: true, maxRetries: 1, baseDelayMs: 1 }, context); + setup.settings.retry = { enabled: true, maxRetries: 1, baseDelayMs: 1 }; const { stream, events } = await listen(harness, root); await root.submit({ type: "input", content: "a" }, context); const followUp = await root.submit({ type: "input", content: "f" }, context); @@ -299,20 +304,23 @@ describe("agent events", () => { it("rebuilds a sliding tail window from output trims and appends", async () => { const setup = chatSetup(); const gate = deferred(); - setup.registry.tools.add({ - name: "tail", - description: "Prints lines", - parameters: Type.Object({}), - outputLimits: { maxLines: 3, retain: "tail" }, - execute: async (_args, api) => { - for (let line = 0; line < 6; line++) { - api.output(`line ${line}\n`); - await new Promise((resolve) => setTimeout(resolve, 120)); - } - await gate.promise; - return {}; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "tail", + description: "Prints lines", + parameters: Type.Object({}), + outputLimits: { maxLines: 3, retain: "tail" }, + execute: async (_args, api) => { + for (let line = 0; line < 6; line++) { + api.output(`line ${line}\n`); + await new Promise((resolve) => setTimeout(resolve, 120)); + } + await gate.promise; + return {}; + }, + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("tail", {}, { id: "c1" })], { stopReason: "toolUse" }), fauxAssistantMessage([fauxText("done")]), @@ -389,12 +397,15 @@ describe("agent events", () => { it("ends a call that never runs and a tool aborted with its generation", async () => { const setup = chatSetup(); - setup.registry.tools.add({ - name: "wait", - description: "Waits until aborted", - parameters: Type.Object({}), - execute: (_args, _api, callContext) => aborted(callContext.abortSignal!), - }); + addTool( + setup.registry, + defineTool({ + name: "wait", + description: "Waits until aborted", + parameters: Type.Object({}), + execute: (_args, _api, callContext) => aborted(callContext.abortSignal!), + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("ghost", {}, { id: "c1" }), fauxToolCall("wait", {}, { id: "c2" })], { stopReason: "toolUse", @@ -436,15 +447,18 @@ describe("agent events", () => { it("reports a steer without run events, a reset as an appended entry, and nothing for other conversations", async () => { const setup = chatSetup(); const gate = deferred(); - setup.registry.tools.add({ - name: "hold", - description: "Waits", - parameters: Type.Object({}), - execute: async () => { - await gate.promise; - return {}; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "hold", + description: "Waits", + parameters: Type.Object({}), + execute: async () => { + await gate.promise; + return {}; + }, + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("hold", {}, { id: "c1" })], { stopReason: "toolUse" }), fauxAssistantMessage([fauxText("done")]), @@ -551,7 +565,7 @@ describe("agent events", () => { live.tools![0]!.status = "done"; }); await root.commit((tx) => tx.retireDoc(UsageDoc, root.id), context); - await root.commit((tx) => tx.retireDoc(ConversationConfig, root.id), context); + await root.commit((tx) => tx.retireDoc(AgentDoc, root.id), context); await drained(); expect(batches.map((batch) => batch.filter((event) => event.type !== "task_failed"))).toEqual([ [{ type: "message_start", message: partial }], @@ -563,7 +577,7 @@ describe("agent events", () => { [{ type: "tool_execution_end", toolCallId: "c1", toolName: "t" }], // Retired documents read as their initial values. [{ type: "usage_changed", usage: { models: {}, tools: {} } }], - [{ type: "config_changed", config: { thinkingLevel: "off", activeTools: [] } }], + [{ type: "agent_changed", agent: {} }], ]); await stream.stop(); await harness.close(context); diff --git a/packages/durable/test/harness-generation-recovery.test.ts b/packages/durable/test/harness-generation-recovery.test.ts index 37f9837d4..68aa54bcd 100644 --- a/packages/durable/test/harness-generation-recovery.test.ts +++ b/packages/durable/test/harness-generation-recovery.test.ts @@ -6,6 +6,7 @@ import { AssistantEntry, type Harness, LiveDoc, type TaskId } from "@earendil-wo import { afterEach, describe, expect, it } from "vitest"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { allEntries, type ChatSetup, chatSetup, openChat, textOf, unanswered, waitFor } from "./chat-support.ts"; +import { addSection } from "./harness-support.ts"; import { context } from "./session-support.ts"; import { aborted, deferred } from "./task-support.ts"; @@ -42,7 +43,7 @@ describe("generation recovery", () => { const setup = chatSetup(); const reached = deferred(); let block = true; - setup.registry.systemPrompt.section("preamble", async (_input, ctx) => { + addSection(setup.registry, "preamble", async (_input, ctx) => { if (block) { block = false; reached.resolve(); @@ -73,7 +74,7 @@ describe("generation recovery", () => { it("resends a request interrupted before any partial without repeating preparation", async () => { const path = await sqlitePath(); const setup = chatSetup(); - setup.registry.systemPrompt.section("preamble", () => "p", { tag: false }); + addSection(setup.registry, "preamble", () => "p", { tag: false }); const reached = deferred(); const sent: string[][] = []; const timeouts: (number | undefined)[] = []; @@ -89,7 +90,7 @@ describe("generation recovery", () => { }, ]); let opened = await open(path, setup); - await opened.root.setStreamOptions({ timeoutMs: 1234 }, context); + setup.settings.stream = { timeoutMs: 1234 }; opened.harness.resume(); const id = (await opened.root.submit({ type: "input", content: "hi" }, context)).id; await reached.promise; @@ -104,7 +105,7 @@ describe("generation recovery", () => { streamOptions: { timeoutMs: 1234 }, }); // The resend uses the pinned request, not options changed after preparation. - await opened.root.setStreamOptions({ timeoutMs: 999 }, context); + setup.settings.stream = { timeoutMs: 999 }; expect(await opened.harness.snapshot(LiveDoc, opened.root.id, context)).toMatchObject({ generation: { attempt: 1 }, }); @@ -171,7 +172,7 @@ describe("generation recovery", () => { ]); let opened = await open(path, setup); opened.harness.resume(); - await opened.root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 60_000 }, context); + setup.settings.retry = { enabled: true, maxRetries: 2, baseDelayMs: 60_000 }; const id = (await opened.root.submit({ type: "input", content: "hi" }, context)).id; await waitFor(async () => { const live = await opened.harness.snapshot(LiveDoc, opened.root.id, context); @@ -205,7 +206,7 @@ describe("generation recovery", () => { setup.faux.setResponses([fauxAssistantMessage("deferred answer")]); let opened = await open(path, setup); opened.harness.resume(); - await opened.root.setStreamOptions({ deferred: true }, context); + setup.settings.stream = { deferred: true }; const id = (await opened.root.submit({ type: "input", content: "hi" }, context)).id; await waitFor(async () => { const live = await opened.harness.snapshot(LiveDoc, opened.root.id, context); @@ -248,7 +249,7 @@ describe("generation recovery", () => { await opened.harness.close(context); opened = await open(path, setup); - await opened.root.setStreamOptions({ deferred: true }, context); + setup.settings.stream = { deferred: true }; opened.harness.resume(); const polling = (await opened.root.submit({ type: "input", content: "two" }, context)).id; await waitFor(async () => { @@ -269,7 +270,7 @@ describe("generation recovery", () => { it("runs a print-style turn and reads the durable answer after reopen", async () => { const path = await sqlitePath(); const setup = chatSetup(); - setup.registry.systemPrompt.section("preamble", () => "You are terse.", { tag: false }); + addSection(setup.registry, "preamble", () => "You are terse.", { tag: false }); setup.faux.setResponses([fauxAssistantMessage("42")]); let opened = await open(path, setup); opened.harness.resume(); diff --git a/packages/durable/test/harness-generation.test.ts b/packages/durable/test/harness-generation.test.ts index 0266d3364..0370816fa 100644 --- a/packages/durable/test/harness-generation.test.ts +++ b/packages/durable/test/harness-generation.test.ts @@ -10,21 +10,25 @@ import { AssistantEntry, type CommitPublication, type Conversation, - ConversationConfig, createRegistry, defineDoc, + defineExtension, GenerationTask, Harness, + type HarnessSettings, LiveDoc, type LiveState, MemoryStorage, type RegistrySnapshot, type TaskId, UserEntry, + wrapSection, } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; +import { resolveSettings } from "../src/harness/agent.ts"; import type { SessionImpl } from "../src/session/session.ts"; import { allEntries, type ChatSetup, chatSetup, openChat, textOf, unanswered, waitFor } from "./chat-support.ts"; +import { addSection } from "./harness-support.ts"; import { ControlledStorage, context } from "./session-support.ts"; const ERROR_503 = fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 Service Unavailable" }); @@ -83,7 +87,7 @@ function livePublications(harness: Harness): LiveState[] { describe("generation", () => { it("answers an input and settles its submission", async () => { const setup = chatSetup(); - setup.registry.systemPrompt.section("preamble", () => "You are helpful.", { tag: false }); + addSection(setup.registry, "preamble", () => "You are helpful.", { tag: false }); setup.faux.setResponses([fauxAssistantMessage("Hello there")]); const { harness, root } = await openChat(new MemoryStorage(), setup); harness.resume(); @@ -162,7 +166,7 @@ describe("generation", () => { const unset = await (await plain.submit({ type: "input", content: "hi" }, context)).wait(context); expect(unset).toMatchObject({ status: "unanswered", reason: "no_model" }); - await root.setModel({ provider: "faux", modelId: "missing" }, context); + await root.configure({ model: { provider: "faux", modelId: "missing" } }, context); const unknown = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context); expect(unknown).toMatchObject({ status: "unanswered", reason: "no_model", entry: expect.any(Number) }); expect((await allEntries(root)).map((entry) => entry.kind)).toEqual(["pi.user"]); @@ -181,10 +185,10 @@ describe("generation", () => { it("retries a retryable error after a durable backoff and then answers", async () => { const setup = chatSetup(); - setup.registry.systemPrompt.section("preamble", () => "p", { tag: false }); + addSection(setup.registry, "preamble", () => "p", { tag: false }); setup.faux.setResponses([ERROR_503, fauxAssistantMessage("recovered")]); + setup.settings.retry = { enabled: true, maxRetries: 3, baseDelayMs: 1 }; const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setRetryPolicy({ enabled: true, maxRetries: 3, baseDelayMs: 1 }, context); const values = livePublications(harness); harness.resume(); const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context); @@ -201,8 +205,8 @@ describe("generation", () => { it("fails with model_error once retries are exhausted", async () => { const setup = chatSetup(); setup.faux.setResponses([ERROR_503, ERROR_503, fauxAssistantMessage("never")]); + setup.settings.retry = { enabled: true, maxRetries: 1, baseDelayMs: 1 }; const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setRetryPolicy({ enabled: true, maxRetries: 1, baseDelayMs: 1 }, context); harness.resume(); const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context); expect(settled).toMatchObject({ status: "unanswered", reason: "model_error", detail: "503 Service Unavailable" }); @@ -214,8 +218,8 @@ describe("generation", () => { it("fails a retryable error without retrying when the retry policy is disabled", async () => { const setup = chatSetup(); setup.faux.setResponses([ERROR_503, fauxAssistantMessage("never")]); + setup.settings.retry = { enabled: false }; const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setRetryPolicy({ enabled: false, maxRetries: 3, baseDelayMs: 1 }, context); harness.resume(); const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context); expect(settled).toMatchObject({ status: "unanswered", reason: "model_error" }); @@ -225,10 +229,17 @@ describe("generation", () => { it("reports section wrapper failures while preparing", async () => { const setup = chatSetup(); - setup.registry.systemPrompt.section("cwd", () => "/repo"); - setup.registry.systemPrompt.wrap("cwd", "broken", () => { - throw new Error("wrapper failed"); - }); + addSection(setup.registry, "cwd", () => "/repo"); + setup.registry.install( + defineExtension({ + name: "broken", + wraps: [ + wrapSection("cwd", () => { + throw new Error("wrapper failed"); + }), + ], + }), + ); setup.faux.setResponses([fauxAssistantMessage("ok")]); const { harness, root } = await openChat(new MemoryStorage(), setup); harness.resume(); @@ -259,8 +270,8 @@ describe("generation", () => { it("polls a deferred response until it is ready", async () => { const setup = chatSetup({ deferred: { pendingFetches: 1, pollAfterMs: 1 } }); setup.faux.setResponses([fauxAssistantMessage("deferred answer")]); + setup.settings.stream = { deferred: true }; const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setStreamOptions({ deferred: true }, context); const values = livePublications(harness); harness.resume(); const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context); @@ -300,8 +311,8 @@ describe("generation", () => { it("cancels a deferred response when aborted during polling", async () => { const setup = chatSetup({ deferred: { pendingFetches: 100, pollAfterMs: 60_000 } }); setup.faux.setResponses([fauxAssistantMessage("never")]); + setup.settings.stream = { deferred: true }; const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setStreamOptions({ deferred: true }, context); harness.resume(); const submission = await root.submit({ type: "input", content: "hi" }, context); const taskId = await runTask(harness, root); @@ -324,8 +335,8 @@ describe("generation", () => { }, }); const setup: ChatSetup = { ...base, models }; + setup.settings.stream = { deferred: true }; const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setStreamOptions({ deferred: true }, context); harness.resume(); const submission = await root.submit({ type: "input", content: "hi" }, context); const taskId = await runTask(harness, root); @@ -350,40 +361,73 @@ describe("generation", () => { return fauxAssistantMessage("b"); }, ]); + setup.settings.stream = { timeoutMs: 1234, headers: { "x-test": "1" } }; const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setStreamOptions({ timeoutMs: 1234, headers: { "x-test": "1" } }, context); - await root.setThinkingLevel("high", context); + await root.configure({ thinkingLevel: "high" }, context); harness.resume(); await (await root.submit({ type: "input", content: "one" }, context)).wait(context); - await root.setThinkingLevel("off", context); + // Both are read at the next preparation: the thinking level from pi.agent, the stream options live from settings. + await root.configure({ thinkingLevel: null }, context); + setup.settings.stream = { timeoutMs: 99 }; await (await root.submit({ type: "input", content: "two" }, context)).wait(context); expect(seen[0]).toMatchObject({ timeoutMs: 1234, headers: { "x-test": "1" }, reasoning: "high" }); expect(seen[0]!.signal).toBeInstanceOf(AbortSignal); expect(seen[1]!.reasoning).toBeUndefined(); + expect(seen[1]).toMatchObject({ timeoutMs: 99 }); + expect(seen[1]!.headers).toBeUndefined(); await harness.close(context); }); - it("reads and writes stream options and the retry policy", async () => { - const { harness, root } = await openChat(new MemoryStorage(), chatSetup()); - expect(await root.getStreamOptions(context)).toEqual({}); - expect(await root.getRetryPolicy(context)).toEqual({ - enabled: true, - maxRetries: 3, - baseDelayMs: 2000, - maxAgentDelayMs: 60000, - }); - await root.setStreamOptions({ cacheRetention: "long", deferred: { window: "1h" } }, context); - await root.setRetryPolicy({ enabled: false, maxRetries: 0, baseDelayMs: 5 }, context); - expect(await root.getStreamOptions(context)).toEqual({ cacheRetention: "long", deferred: { window: "1h" } }); - expect(await root.getRetryPolicy(context)).toEqual({ enabled: false, maxRetries: 0, baseDelayMs: 5 }); - await root.setRetryPolicy(undefined, context); - expect(await root.getRetryPolicy(context)).toMatchObject({ enabled: true, maxRetries: 3 }); - await expect( - root.setStreamOptions({ timeoutMs: undefined, headers: { a: undefined } } as never, context), - ).rejects.toThrow(); + it("reads settings through getters at every decision", async () => { + const setup = chatSetup(); + let timeoutMs = 111; + const seen: (number | undefined)[] = []; + setup.faux.setResponses([ + (_context, options) => { + seen.push(options?.timeoutMs); + // The user changes the setting while the first attempt runs. + timeoutMs = 222; + return ERROR_503; + }, + (_context, options) => { + seen.push(options?.timeoutMs); + return fauxAssistantMessage("ok"); + }, + ]); + const settings: HarnessSettings = { + get stream() { + return { timeoutMs }; + }, + retry: { baseDelayMs: 1 }, + }; + const harness = await Harness.open( + new MemoryStorage(), + { models: setup.models, registry: setup.registry, settings }, + context, + ); + const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); + harness.resume(); + expect((await (await root.submit({ type: "input", content: "hi" }, context)).wait(context)).status).toBe("done"); + // The retry prepares again, so it resolves the settings again and sends the new timeout. + expect(seen).toEqual([111, 222]); await harness.close(context); }); + it("resolves settings over the built-in defaults", () => { + expect(resolveSettings(undefined)).toEqual({ + stream: {}, + retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60000 }, + compaction: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000, backgroundTokens: 32768 }, + toolExecution: "parallel", + steeringMode: "one-at-a-time", + followUpMode: "one-at-a-time", + }); + expect(resolveSettings({ retry: { enabled: false }, compaction: { backgroundTokens: 0 } })).toMatchObject({ + retry: { enabled: false, maxRetries: 3, baseDelayMs: 2000 }, + compaction: { enabled: true, backgroundTokens: 0 }, + }); + }); + it("renders sections that read conversation documents through input.read", async () => { const Agent = defineDoc<{ cwd: string; kind: string }>({ kind: "test.agent", @@ -394,11 +438,12 @@ describe("generation", () => { initial: () => ({ cwd: "/", kind: "main" }), }); const setup = chatSetup(); - setup.registry.systemPrompt.section( + addSection( + setup.registry, "cwd", async (input, ctx) => (await input.read.snapshot(Agent, input.conversationId, ctx))?.cwd, ); - setup.registry.systemPrompt.section("agents", async (input, ctx) => + addSection(setup.registry, "agents", async (input, ctx) => (await input.read.snapshot(Agent, input.conversationId, ctx))?.kind === "sub" ? undefined : "Read AGENTS.md", ); setup.faux.setResponses([fauxAssistantMessage("a"), fauxAssistantMessage("b")]); @@ -409,8 +454,8 @@ describe("generation", () => { const sub = await harness.createConversation( { ownership: { kind: "ownerless" }, + agent: { model: { provider: "faux", modelId: "faux-1" } }, init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" }; const agent = await tx.doc(Agent, id); agent.kind = "sub"; agent.cwd = "/sub"; @@ -526,14 +571,12 @@ describe("generation", () => { it("rejects a registry without the built-in tasks", async () => { const empty = createRegistry().snapshot(); const snapshot: RegistrySnapshot = { + installed: () => [], + extension: () => undefined, tools: () => [], - tool: () => undefined, - toolNames: () => [], - task: (name) => (name === "pi.generation" ? undefined : empty.task(name)), - hooks: () => [], sections: () => [], - failures: () => [], - conversationSetups: () => [], + tasks: () => empty.tasks().filter((task) => task.definition.name !== "pi.generation"), + task: (name) => (name === "pi.generation" ? undefined : empty.task(name)), }; await expect( Harness.open( @@ -541,6 +584,6 @@ describe("generation", () => { { models: chatSetup().models, registry: { snapshot: () => snapshot, subscribe: () => () => {} } }, context, ), - ).rejects.toThrow("Registry lacks built-in task pi.generation, conversation setup pi"); + ).rejects.toThrow("Registry lacks built-in tasks pi.generation"); }); }); diff --git a/packages/durable/test/harness-inbox.test.ts b/packages/durable/test/harness-inbox.test.ts index 9b7a98080..a0a685dd0 100644 --- a/packages/durable/test/harness-inbox.test.ts +++ b/packages/durable/test/harness-inbox.test.ts @@ -14,6 +14,8 @@ import { } from "@earendil-works/pi-ai"; import { type Conversation, + defineDoc, + defineTool, type EntryRecord, GenerationTask, type Harness, @@ -32,7 +34,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { recordUsage } from "../src/harness/usage.ts"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { allEntries, type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts"; -import { context, documentChanges } from "./session-support.ts"; +import { addHooks, addTool } from "./harness-support.ts"; +import { ControlledStorage, context, documentChanges, flush } from "./session-support.ts"; import { aborted, type Deferred, deferred } from "./task-support.ts"; const directories = new Set(); @@ -60,15 +63,18 @@ function gated(message: AssistantMessage): { step: FauxResponseStep; reached: Pr /** Register a `hold` tool whose calls wait for `gate` and then return `result`. */ function holdTool(setup: ChatSetup, gate: Deferred, result: ToolExecutionResult = { content: [] }): void { - setup.registry.tools.add({ - name: "hold", - description: "Waits for the test", - parameters: Type.Object({}), - execute: async () => { - await gate.promise; - return result; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "hold", + description: "Waits for the test", + parameters: Type.Object({}), + execute: async () => { + await gate.promise; + return result; + }, + }), + ); } const HOLD = fauxAssistantMessage([fauxToolCall("hold", {}, { id: "c1" })], { stopReason: "toolUse" }); @@ -141,9 +147,7 @@ describe("inbox", () => { const first = gated(answer("first")); setup.faux.setResponses([first.step, answer("both")]); const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setFollowUpMode("all", context); - expect(await root.getFollowUpMode(context)).toBe("all"); - expect(await root.getSteeringMode(context)).toBe("one-at-a-time"); + setup.settings.followUpMode = "all"; await root.submit({ type: "input", content: "a" }, context); await first.reached; const f1 = await root.submit({ type: "input", content: "f1" }, context); @@ -162,6 +166,40 @@ describe("inbox", () => { await harness.close(context); }); + it("reads queue modes when the final boundary's commit runs on the Session line", async () => { + const setup = chatSetup(); + const first = gated(answer("first")); + setup.faux.setResponses([first.step, answer("both")]); + const yielded = deferred(); + addHooks(setup.registry, GenerationTask, { onYield: () => void yielded.resolve() }); + const storage = new ControlledStorage(); + const { harness, root } = await openChat(storage, setup); + await root.submit({ type: "input", content: "a" }, context); + await first.reached; + const f1 = await root.submit({ type: "input", content: "f1" }, context); + const f2 = await root.submit({ type: "input", content: "f2" }, context); + // Occupy the line, let the answer queue its boundary commit behind it, then change the mode. + const held = storage.holdCommits(); + const Marker = defineDoc<{ n: number }>({ + kind: "test.marker", + version: 1, + scope: "session", + initial: () => ({ n: 0 }), + }); + const occupying = root.commit(async (tx) => void (await tx.doc(Marker)).n++, context); + await held.entered; + first.release(); + await yielded.promise; + await flush(); + setup.settings.followUpMode = "all"; + held.release(); + await occupying; + const settled = await f2.wait(context); + expect(await status(f1)).toEqual({ ...settled, id: f1.id, entry: expect.any(Number) }); + expect(setup.faux.state.callCount).toBe(2); + await harness.close(context); + }); + it("adds steers to the run at the postTools boundary and holds follow-ups for the final boundary", async () => { const setup = chatSetup(); const gate = deferred(); @@ -327,7 +365,7 @@ describe("inbox", () => { const first = gated(answer("first")); setup.faux.setResponses([first.step, answer("second")]); let yields = 0; - setup.registry.hooks.add(GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "more" } : undefined) }); + addHooks(setup.registry, GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "more" } : undefined) }); const { harness, root } = await openChat(new MemoryStorage(), setup); const input = await root.submit({ type: "input", content: "a" }, context); await first.reached; @@ -475,7 +513,7 @@ describe("inbox", () => { const first = gated(answer("first")); setup.faux.setResponses([first.step, answer("second")]); let yields = 0; - setup.registry.hooks.add(GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "more" } : undefined) }); + addHooks(setup.registry, GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "more" } : undefined) }); const { harness, root } = await openChat(new MemoryStorage(), setup); const input = await root.submit({ type: "input", content: "a" }, context); await first.reached; @@ -499,7 +537,7 @@ describe("inbox", () => { const setup = chatSetup(); const first = gated(answer("first")); setup.faux.setResponses([first.step]); - setup.registry.hooks.add(GenerationTask, { onYield: () => ({ continue: "more" }) }); + addHooks(setup.registry, GenerationTask, { onYield: () => ({ continue: "more" }) }); const { harness, root } = await openChat(new MemoryStorage(), setup); const input = await root.submit({ type: "input", content: "a" }, context); await first.reached; @@ -519,7 +557,7 @@ describe("inbox", () => { holdTool(setup, gate); setup.faux.setResponses([HOLD, answer("after tools"), answer("follow-up")]); const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setSteeringMode("all", context); + setup.settings.steeringMode = "all"; const input = await root.submit({ type: "input", content: "a" }, context); await toolRunning(harness, root); const s1 = await root.submit({ type: "input", content: "s1", whenBusy: "steer" }, context); @@ -591,15 +629,18 @@ describe("inbox", () => { const firstGate = deferred(); const secondGate = deferred(); holdTool(setup, firstGate, { content: [], control: { handoff: "one" } }); - setup.registry.tools.add({ - name: "later", - description: "Finishes first", - parameters: Type.Object({}), - execute: async () => { - await secondGate.promise; - return { content: [], control: { handoff: "two" } }; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "later", + description: "Finishes first", + parameters: Type.Object({}), + execute: async () => { + await secondGate.promise; + return { content: [], control: { handoff: "two" } }; + }, + }), + ); const round = fauxAssistantMessage( [fauxToolCall("hold", {}, { id: "c1" }), fauxToolCall("later", {}, { id: "c2" })], { stopReason: "toolUse" }, @@ -787,11 +828,11 @@ describe("usage", () => { const gate = deferred(); gate.resolve(); holdTool(setup, gate); - setup.registry.hooks.add(ToolTask, { afterTool: (_call, result) => ({ ...result, usage: spent }) }); + addHooks(setup.registry, ToolTask, { afterTool: (_call, result) => ({ ...result, usage: spent }) }); const retryable = fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 Service Unavailable" }); setup.faux.setResponses([retryable, HOLD, answer("done"), answer("x".repeat(400))]); const { harness, root } = await openChat(new MemoryStorage(), setup); - await root.setRetryPolicy({ enabled: true, maxRetries: 1, baseDelayMs: 1 }, context); + setup.settings.retry = { enabled: true, maxRetries: 1, baseDelayMs: 1 }; await (await root.submit({ type: "input", content: "a" }, context)).wait(context); expect((await harness.snapshot(UsageDoc, root.id, context))!.tools).toEqual({ hold: spent }); diff --git a/packages/durable/test/harness-inspect.test.ts b/packages/durable/test/harness-inspect.test.ts index c905f1759..78aa8f20d 100644 --- a/packages/durable/test/harness-inspect.test.ts +++ b/packages/durable/test/harness-inspect.test.ts @@ -1,7 +1,6 @@ import { defineTask, MemoryStorage, type TaskId, type TaskInspection } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; import { chatSetup, openChat, unanswered, waitFor } from "./chat-support.ts"; -import { tool } from "./harness-support.ts"; import { context } from "./session-support.ts"; import { completed, deferred, eventually, openTasks } from "./task-support.ts"; @@ -143,14 +142,10 @@ describe("Harness.inspect()", () => { await harness.close(context); }); - it("lists unsettled submissions and registry failures", async () => { + it("lists unsettled submissions", async () => { const setup = chatSetup(); const busy = unanswered(); setup.faux.setResponses([busy.step]); - setup.registry.tools.add(tool("read")); - setup.registry.tools.wrap("read", "broken", () => { - throw new Error("wrapper failed"); - }); const { harness, root } = await openChat(new MemoryStorage(), setup); const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context); await other.submit({ type: "write", entry: { kind: "note" } }, context); @@ -162,7 +157,6 @@ describe("Harness.inspect()", () => { expect(inspection.tasks.map((entry) => [entry.record.kind, entry.state.kind])).toEqual([ ["pi.generation", "running"], ]); - expect(inspection.registry).toEqual([{ kind: "tool", name: "read", error: new Error("wrapper failed") }]); await harness.close(context); }); }); diff --git a/packages/durable/test/harness-live-deltas.test.ts b/packages/durable/test/harness-live-deltas.test.ts index ca62b32d8..5cf68d464 100644 --- a/packages/durable/test/harness-live-deltas.test.ts +++ b/packages/durable/test/harness-live-deltas.test.ts @@ -2,6 +2,7 @@ import type { Op } from "@earendil-works/chord/delta"; import { fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai"; import { type CommitPublication, + defineTool, type Harness, LiveDoc, type LiveState, @@ -11,6 +12,7 @@ import { } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; import { chatSetup, openChat, waitFor } from "./chat-support.ts"; +import { addTool } from "./harness-support.ts"; import { context, documentChanges } from "./session-support.ts"; type Action = (api: ToolExecutionApi) => void | Promise; @@ -23,24 +25,27 @@ async function drive(outputLimits: ToolRegistration["outputLimits"] = {}) { const setup = chatSetup(); const actions: ((api: ToolExecutionApi) => Promise)[] = []; let wake: (() => void) | undefined; - setup.registry.tools.add({ - name: "drive", - description: "Driven by the test", - parameters: Type.Object({}), - outputLimits, - execute: async (_args, api, callContext) => { - for (;;) { - while (actions.length === 0) { - await new Promise((resolve, reject) => { - wake = resolve; - const signal = callContext.abortSignal!; - signal.addEventListener("abort", () => reject(signal.reason), { once: true }); - }); + addTool( + setup.registry, + defineTool({ + name: "drive", + description: "Driven by the test", + parameters: Type.Object({}), + outputLimits, + execute: async (_args, api, callContext) => { + for (;;) { + while (actions.length === 0) { + await new Promise((resolve, reject) => { + wake = resolve; + const signal = callContext.abortSignal!; + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + } + if (!(await actions.shift()!(api))) return {}; } - if (!(await actions.shift()!(api))) return {}; - } - }, - }); + }, + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("drive", {}, { id: "c1" })], { stopReason: "toolUse" }), fauxAssistantMessage([fauxText("done")]), @@ -94,12 +99,15 @@ function isOutput(op: Op): boolean { describe("pi.live deltas", () => { it("hands a generation over to its tool round and starts a tool with one field write each", async () => { const setup = chatSetup(); - setup.registry.tools.add({ - name: "noop", - description: "noop", - parameters: Type.Object({}), - execute: async () => ({ content: [] }), - }); + addTool( + setup.registry, + defineTool({ + name: "noop", + description: "noop", + parameters: Type.Object({}), + execute: async () => ({ content: [] }), + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("noop", {}, { id: "c1" })], { stopReason: "toolUse" }), fauxAssistantMessage([fauxText("done")]), @@ -288,17 +296,20 @@ describe("pi.live deltas", () => { } const setup = chatSetup(); for (const name of ["first", "second"]) { - setup.registry.tools.add({ - name, - description: name, - parameters: Type.Object({}), - execute: async (_args, api) => { - api.output(`${name} output\n`); - await new Promise((resolve) => setTimeout(resolve, 150)); - api.output(`${name} more\n`); - return {}; - }, - }); + addTool( + setup.registry, + defineTool({ + name, + description: name, + parameters: Type.Object({}), + execute: async (_args, api) => { + api.output(`${name} output\n`); + await new Promise((resolve) => setTimeout(resolve, 150)); + api.output(`${name} more\n`); + return {}; + }, + }), + ); } setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("first", {}, { id: "a" }), fauxToolCall("second", {}, { id: "b" })], { @@ -306,8 +317,8 @@ describe("pi.live deltas", () => { }), fauxAssistantMessage([fauxText("done")]), ]); + setup.settings.toolExecution = "sequential"; const { harness, root } = await openChat(new RecordingStorage(), setup); - await root.setToolExecution("sequential", context); const values = new Map(); harness.subscribeCommits((publication) => { for (const change of documentChanges(publication)) { @@ -331,13 +342,16 @@ describe("pi.live deltas", () => { it("starts calls the request did not offer as done and marks a faulted tool's slot done without an entry", async () => { const setup = chatSetup(); - setup.registry.tools.add({ - name: "bad", - description: "bad", - parameters: Type.Object({}), - // Not strict JSON: the result commit throws and the scheduler faults the task. - execute: async () => ({ content: [], details: { fn: (() => 1) as never } }), - }); + addTool( + setup.registry, + defineTool({ + name: "bad", + description: "bad", + parameters: Type.Object({}), + // Not strict JSON: the result commit throws and the scheduler faults the task. + execute: async () => ({ content: [], details: { fn: (() => 1) as never } }), + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("ghost", {}, { id: "g" }), fauxToolCall("bad", {}, { id: "b" })], { stopReason: "toolUse", @@ -370,12 +384,15 @@ describe("pi.live deltas", () => { it("commits the tool-calling answer, its tool tasks, the generation's wait, and the tool round in one commit", async () => { const setup = chatSetup(); - setup.registry.tools.add({ - name: "noop", - description: "noop", - parameters: Type.Object({}), - execute: async () => ({ content: [] }), - }); + addTool( + setup.registry, + defineTool({ + name: "noop", + description: "noop", + parameters: Type.Object({}), + execute: async () => ({ content: [] }), + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("noop", {}, { id: "a" }), fauxToolCall("noop", {}, { id: "b" })], { stopReason: "toolUse", diff --git a/packages/durable/test/harness-ownership.test.ts b/packages/durable/test/harness-ownership.test.ts index 52ca22d06..c46b51a29 100644 --- a/packages/durable/test/harness-ownership.test.ts +++ b/packages/durable/test/harness-ownership.test.ts @@ -4,12 +4,13 @@ import { join } from "node:path"; import { type AssistantMessage, fauxAssistantMessage, fauxText, fauxToolCall, Type } from "@earendil-works/pi-ai"; import { type Conversation, - ConversationConfig, type ConversationHandle, type ConversationId, + configure, createSession, defineDoc, defineTask, + defineTool, type EntryId, type Harness, LiveDoc, @@ -22,6 +23,7 @@ import { import { afterEach, describe, expect, it } from "vitest"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { chatSetup, openChat } from "./chat-support.ts"; +import { addTask, addTool } from "./harness-support.ts"; import { context } from "./session-support.ts"; import { aborted, type Deferred, deferred, openTasks, settled } from "./task-support.ts"; @@ -592,23 +594,29 @@ describe("owned conversations from tools and supervisors", () => { let handle: ConversationHandle | undefined; let missing: ConversationHandle | undefined = {} as ConversationHandle; let submission: Submission | undefined; - setup.registry.tools.add({ - name: "delegate", - description: "Delegates", - parameters: Type.Object({}), - execute: async (_args, api, callContext) => { - const child = await api.commit(async (tx) => { - const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - (await tx.doc(ConversationConfig, created.id)).model = { provider: "faux", modelId: "faux-1" }; - return created.id; - }, callContext); - missing = await api.conversation(99_999 as ConversationId, callContext); - handle = (await api.conversation(child, callContext))!; - submission = await handle.submit({ type: "input", content: "child task", requestId: "child" }, callContext); - const settled = await submission.wait(callContext); - return { content: [{ type: "text", text: settled.status }] }; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "delegate", + description: "Delegates", + parameters: Type.Object({}), + execute: async (_args, api, callContext) => { + const child = await api.commit(async (tx) => { + const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); + await configure(tx, created.id, { model: { provider: "faux", modelId: "faux-1" } }); + return created.id; + }, callContext); + missing = await api.conversation(99_999 as ConversationId, callContext); + handle = (await api.conversation(child, callContext))!; + submission = await handle.submit( + { type: "input", content: "child task", requestId: "child" }, + callContext, + ); + const settled = await submission.wait(callContext); + return { content: [{ type: "text", text: settled.status }] }; + }, + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("delegate", {}, { id: "c1" })], { stopReason: "toolUse" }), fauxAssistantMessage([fauxText("child answer")]), @@ -641,26 +649,29 @@ describe("owned conversations from tools and supervisors", () => { const ready = deferred<{ child: ConversationId; taskId: TaskId }>(); const go = deferred(); const queued = deferred<{ submitting: Promise }>(); - setup.registry.tools.add({ - name: "delegate", - description: "Delegates late", - parameters: Type.Object({}), - execute: async (_args, api, callContext) => { - const child = await api.commit(async (tx) => { - const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - return created.id; - }, callContext); - const handle = (await api.conversation(child, callContext))!; - ready.resolve({ child, taskId: api.taskId }); - await go.promise; - // Called while the invocation is alive, with a context that is not the call's: the handle binds it to the - // invocation. It reaches the line only after the abort mark. - const submitting = handle.submit({ type: "input", content: "late" }, context); - submitting.catch(() => {}); - queued.resolve({ submitting }); - return aborted(callContext.abortSignal!); - }, - }); + addTool( + setup.registry, + defineTool({ + name: "delegate", + description: "Delegates late", + parameters: Type.Object({}), + execute: async (_args, api, callContext) => { + const child = await api.commit(async (tx) => { + const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); + return created.id; + }, callContext); + const handle = (await api.conversation(child, callContext))!; + ready.resolve({ child, taskId: api.taskId }); + await go.promise; + // Called while the invocation is alive, with a context that is not the call's: the handle binds it to the + // invocation. It reaches the line only after the abort mark. + const submitting = handle.submit({ type: "input", content: "late" }, context); + submitting.catch(() => {}); + queued.resolve({ submitting }); + return aborted(callContext.abortSignal!); + }, + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("delegate", {}, { id: "c1" })], { stopReason: "toolUse" }), ]); @@ -691,28 +702,31 @@ describe("owned conversations from tools and supervisors", () => { const setup = chatSetup(); let child: ConversationId | undefined; const toolWait = deferred(); - setup.registry.tools.add({ - name: "delegate", - description: "Delegates", - parameters: Type.Object({}), - execute: async (_args, api, callContext) => { - child = await api.commit(async (tx) => { - const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - (await tx.doc(ConversationConfig, created.id)).model = { provider: "faux", modelId: "faux-1" }; - return created.id; - }, callContext); - const submission = await (await api.conversation(child, callContext))!.submit( - { type: "input", content: "child task" }, - callContext, - ); - try { - return { content: [{ type: "text", text: (await submission.wait(callContext)).status }] }; - } catch (error) { - toolWait.resolve((error as Error).message); - throw error; - } - }, - }); + addTool( + setup.registry, + defineTool({ + name: "delegate", + description: "Delegates", + parameters: Type.Object({}), + execute: async (_args, api, callContext) => { + child = await api.commit(async (tx) => { + const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); + await configure(tx, created.id, { model: { provider: "faux", modelId: "faux-1" } }); + return created.id; + }, callContext); + const submission = await (await api.conversation(child, callContext))!.submit( + { type: "input", content: "child task" }, + callContext, + ); + try { + return { content: [{ type: "text", text: (await submission.wait(callContext)).status }] }; + } catch (error) { + toolWait.resolve((error as Error).message); + throw error; + } + }, + }), + ); const childRun = gated(fauxAssistantMessage([fauxText("never")])); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("delegate", {}, { id: "c1" })], { stopReason: "toolUse" }), @@ -737,24 +751,27 @@ describe("owned conversations from tools and supervisors", () => { it("lets a running tool abort its owned child and continue", async () => { const setup = chatSetup(); const childRun = gated(fauxAssistantMessage([fauxText("never")])); - setup.registry.tools.add({ - name: "delegate", - description: "Delegates and cancels", - parameters: Type.Object({}), - execute: async (_args, api, callContext) => { - const child = await api.commit(async (tx) => { - const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - (await tx.doc(ConversationConfig, created.id)).model = { provider: "faux", modelId: "faux-1" }; - return created.id; - }, callContext); - const handle = (await api.conversation(child, callContext))!; - const submission = await handle.submit({ type: "input", content: "child task" }, callContext); - await childRun.reached; - await handle.abort(callContext); - const settledChild = await submission.wait(callContext); - return { content: [{ type: "text", text: `child ${settledChild.status}` }] }; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "delegate", + description: "Delegates and cancels", + parameters: Type.Object({}), + execute: async (_args, api, callContext) => { + const child = await api.commit(async (tx) => { + const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); + await configure(tx, created.id, { model: { provider: "faux", modelId: "faux-1" } }); + return created.id; + }, callContext); + const handle = (await api.conversation(child, callContext))!; + const submission = await handle.submit({ type: "input", content: "child task" }, callContext); + await childRun.reached; + await handle.abort(callContext); + const settledChild = await submission.wait(callContext); + return { content: [{ type: "text", text: `child ${settledChild.status}` }] }; + }, + }), + ); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("delegate", {}, { id: "c1" })], { stopReason: "toolUse" }), childRun.step, @@ -772,26 +789,29 @@ describe("owned conversations from tools and supervisors", () => { const children: TaskId[] = []; const toolRunning = deferred(); const setup = chatSetup(); - setup.registry.tasks.add(Hold); - setup.registry.tools.add({ - name: "spawn", - description: "Starts owned work, then throws or hangs", - parameters: Type.Object({ mode: Type.String() }), - execute: async (args, api, callContext) => { - const child = await api.commit(async (tx) => { - const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - return tx.createTask( - Hold, - { name: `child.${api.callId}` }, - { ownership: { kind: "conversation" }, conversationId: created.id }, - ); - }, callContext); - children.push(child); - if ((args as { mode: string }).mode === "throw") throw new Error("spawn failed"); - toolRunning.resolve(); - return aborted(callContext.abortSignal!); - }, - }); + addTask(setup.registry, Hold); + addTool( + setup.registry, + defineTool({ + name: "spawn", + description: "Starts owned work, then throws or hangs", + parameters: Type.Object({ mode: Type.String() }), + execute: async (args, api, callContext) => { + const child = await api.commit(async (tx) => { + const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); + return tx.createTask( + Hold, + { name: `child.${api.callId}` }, + { ownership: { kind: "conversation" }, conversationId: created.id }, + ); + }, callContext); + children.push(child); + if (args.mode === "throw") throw new Error("spawn failed"); + toolRunning.resolve(); + return aborted(callContext.abortSignal!); + }, + }), + ); const call = (mode: string, id: string) => fauxAssistantMessage([fauxToolCall("spawn", { mode }, { id })], { stopReason: "toolUse" }); setup.faux.setResponses([ @@ -828,26 +848,29 @@ describe("owned conversations from tools and supervisors", () => { const path = join(directory, "session.sqlite"); const children: ConversationId[] = []; const setup = chatSetup(); - setup.registry.tools.add({ - name: "subagent", - description: "Delegates", - parameters: Type.Object({}), - replay: "safe", - execute: async (_args, api, callContext) => { - const child = await api.commit(async (tx) => { - const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0]; - if (existing !== undefined) return existing.id; - const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - (await tx.doc(ConversationConfig, created.id)).model = { provider: "faux", modelId: "faux-1" }; - return created.id; - }, callContext); - children.push(child); - const request = { type: "input", content: "child task", requestId: `subagent:${api.taskId}` } as const; - const submission = await (await api.conversation(child, callContext))!.submit(request, callContext); - const settled = await submission.wait(callContext); - return { content: [{ type: "text", text: settled.status }] }; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "subagent", + description: "Delegates", + parameters: Type.Object({}), + replay: "safe", + execute: async (_args, api, callContext) => { + const child = await api.commit(async (tx) => { + const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0]; + if (existing !== undefined) return existing.id; + const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); + await configure(tx, created.id, { model: { provider: "faux", modelId: "faux-1" } }); + return created.id; + }, callContext); + children.push(child); + const request = { type: "input", content: "child task", requestId: `subagent:${api.taskId}` } as const; + const submission = await (await api.conversation(child, callContext))!.submit(request, callContext); + const settled = await submission.wait(callContext); + return { content: [{ type: "text", text: settled.status }] }; + }, + }), + ); const childRun = gated(fauxAssistantMessage([fauxText("never")])); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("subagent", {}, { id: "c1" })], { stopReason: "toolUse" }), @@ -907,7 +930,7 @@ describe("owned conversations from tools and supervisors", () => { runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx), }); const setup = chatSetup(); - setup.registry.tasks.add(Supervisor); + addTask(setup.registry, Supervisor); const first = gated(fauxAssistantMessage([fauxText("never")])); setup.faux.setResponses([first.step, fauxAssistantMessage([fauxText("answer")])]); let opened = await openChat(await openNodeSqliteStorage(path), setup); @@ -919,7 +942,7 @@ describe("owned conversations from tools and supervisors", () => { { ownership: { kind: "conversation" }, background: true }, ); const created = await tx.createConversation({ ownership: { kind: "task", taskId: supervisor } }); - (await tx.doc(ConversationConfig, created.id)).model = { provider: "faux", modelId: "faux-1" }; + await configure(tx, created.id, { model: { provider: "faux", modelId: "faux-1" } }); (await tx.doc(Children, root.id)).child = created.id; return { supervisor, child: created.id }; }, context); diff --git a/packages/durable/test/harness-prompt.test.ts b/packages/durable/test/harness-prompt.test.ts index bf14f128b..8c47b19db 100644 --- a/packages/durable/test/harness-prompt.test.ts +++ b/packages/durable/test/harness-prompt.test.ts @@ -3,16 +3,19 @@ import { getCurrentTools, toToolDeclaration } from "@earendil-works/pi-ai/utils/ import { type Conversation, createRegistry, + defineExtension, type EntryId, MemoryStorage, type PromptInput, type PromptSection, SystemEntry, type ToolRegistration, + wrapSection, } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; -import { desiredTools, planSystemEntries, renderSections, replaySections } from "../src/harness/prompt.ts"; -import { openHarness, user } from "./harness-support.ts"; +import { resolveAgent, resolveSettings } from "../src/harness/agent.ts"; +import { planSystemEntries, renderSections, replaySections } from "../src/harness/prompt.ts"; +import { addSection, openHarness, user } from "./harness-support.ts"; import { context } from "./session-support.ts"; type Planned = { readonly sections: Record; readonly omit?: readonly EntryId[] }; @@ -59,40 +62,44 @@ function section(key: string, render: PromptSection["render"], const input: PromptInput = { conversationId: 1 as never, - tools: [], + agent: { thinkingLevel: "off", extensions: [], tools: [], sections: [] }, + env: undefined, shown: {}, - thinkingLevel: "off", read: { snapshot: async () => undefined, snapshotAsOf: async () => undefined }, }; describe("system prompt preparation", () => { it("renders sections in order with tags, omissions, wrappers, and failures", async () => { const registry = createRegistry(); - registry.systemPrompt.section("preamble", () => "You are helpful.", { tag: false }); - registry.systemPrompt.section("cwd", async () => "/repo"); - registry.systemPrompt.section("skipped", () => undefined); - registry.systemPrompt.section("failing", () => { + addSection(registry, "preamble", () => "You are helpful.", { tag: false }); + addSection(registry, "cwd", async () => "/repo"); + addSection(registry, "skipped", () => undefined); + addSection(registry, "failing", () => { throw new Error("render failed"); }); - registry.systemPrompt.section("new-failing", () => { + addSection(registry, "new-failing", () => { throw new Error("also failed"); }); - registry.systemPrompt.wrap("cwd", "suffix", (inner) => ({ - ...inner, - render: async (value, ctx) => `${await inner.render(value, ctx)} (git)`, - })); + registry.install( + defineExtension({ + name: "git", + wraps: [ + wrapSection("cwd", (inner) => ({ + ...inner, + render: async (value, ctx) => `${await inner.render(value, ctx)} (git)`, + })), + ], + }), + ); const reports: unknown[] = []; const shown = new Map([ ["failing", "\nold\n"], ["cwd", "stale"], ]); - const desired = await renderSections( - registry.snapshot().sections(), - input, - shown, - (error) => reports.push(error), - context, - ); + const agent = resolveAgent(undefined, registry.snapshot(), resolveSettings(undefined), (error) => { + throw error; + }); + const desired = await renderSections(agent.sections, input, shown, (error) => reports.push(error), context); expect([...desired]).toEqual([ ["preamble", "You are helpful."], ["cwd", "\n/repo (git)\n"], @@ -264,23 +271,4 @@ describe("tool loadout preparation", () => { { added: ["a", "b"], sections: { y: "2", x: "1" } }, ]); }); - - it("offers each active name once, only when registered, as composed by wrappers", () => { - const registry = createRegistry(); - const base = { ...declaration("a"), execute: async () => ({}) }; - registry.tools.add(base); - registry.tools.add({ ...declaration("b"), execute: async () => ({}) }); - registry.tools.add({ ...declaration("broken"), execute: async () => ({}) }); - registry.tools.wrap("a", "describe", (tool) => ({ ...tool, description: "wrapped" })); - registry.tools.wrap("broken", "fail", () => { - throw new Error("wrapper failed"); - }); - const snapshot = registry.snapshot(); - const tools = desiredTools(["b", "missing", "a", "b", "broken"], (name) => snapshot.tool(name)); - expect(tools.map((tool) => [tool.name, tool.description])).toEqual([ - ["b", "b"], - ["a", "wrapped"], - ]); - expect(snapshot.failures().map((failure) => failure.name)).toEqual(["broken"]); - }); }); diff --git a/packages/durable/test/harness-registry.test.ts b/packages/durable/test/harness-registry.test.ts index 40b9df551..a42db5ea7 100644 --- a/packages/durable/test/harness-registry.test.ts +++ b/packages/durable/test/harness-registry.test.ts @@ -1,14 +1,23 @@ -import type { JsonValue } from "@earendil-works/chord"; import { Type } from "@earendil-works/pi-ai"; import { + type AgentState, CompactionTask, createRegistry, + defineExtension, defineTask, GenerationTask, + type HarnessSettings, + hook, + type RegistrySnapshot, + section, type ToolRegistration, ToolTask, + wrapSection, + wrapTool, } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; +import { agentHooks, resolveAgent, resolveSettings } from "../src/harness/agent.ts"; +import { context } from "./session-support.ts"; type AppTool = ToolRegistration & { readonly snippet?: string }; @@ -22,233 +31,261 @@ function tool(name: string, extra: Partial = {}): AppTool { }; } -function task(name: string) { - const hooks: { beforeRun(): void } = { beforeRun: () => {} }; - return defineTask({ +function task(name: string, version = 1) { + return defineTask({ name, - version: 1, + version, initial: () => ({ phase: "run" }), phases: { run: async () => {} }, abort: async () => {}, - hooks, }); } -function names(tools: readonly { readonly name: string }[]): string[] { - return tools.map((entry) => entry.name); +function names(items: readonly { readonly name: string }[]): string[] { + return items.map((item) => item.name); +} + +/** Resolve `state` against `snapshot` and `settings`, collecting reports. */ +function resolve( + state: AgentState | undefined, + snapshot: RegistrySnapshot, + settings: HarnessSettings = {}, + reports: unknown[] = [], +) { + return resolveAgent(state, snapshot, resolveSettings(settings), (error) => reports.push(error)); +} + +async function rendered(agent: ReturnType): Promise<[string, string | undefined][]> { + const input = { + conversationId: 1 as never, + agent, + env: undefined, + shown: {}, + read: { snapshot: async () => undefined, snapshotAsOf: async () => undefined }, + }; + return Promise.all( + agent.sections.map(async (item) => [item.key, await item.render(input, context)] as [string, string | undefined]), + ); } describe("registry", () => { - it("adds, lists, and disposes tools by exact token", () => { + it("installs, replaces in place, and uninstalls extensions by name", () => { const registry = createRegistry(); - const read = registry.tools.add(tool("read", { snippet: "Read files" })); - registry.tools.add(tool("bash")); - expect(names(registry.tools.list())).toEqual(["read", "bash"]); - expect(registry.tools.list()[0]!.snippet).toBe("Read files"); - expect(() => registry.tools.add(tool("read"))).toThrow("Tool read is already registered"); - - read.dispose(); - read.dispose(); - expect(names(registry.tools.list())).toEqual(["bash"]); - }); - - it("keeps the original position of a re-registered key and appends new keys", () => { - const registry = createRegistry(); - const a = registry.tools.add(tool("a")); - registry.tools.add(tool("b")); - a.dispose(); - registry.tools.add(tool("c")); - registry.tools.add(tool("a")); - expect(names(registry.tools.list())).toEqual(["a", "b", "c"]); - - const first = registry.systemPrompt.section("first", () => "1"); - registry.systemPrompt.section("second", () => "2"); - registry.batch(() => { - first.dispose(); - registry.systemPrompt.section("third", () => "3"); - registry.systemPrompt.section("first", () => "1b"); - }); - expect(registry.systemPrompt.sections().map((section) => section.key)).toEqual(["first", "second", "third"]); - }); - - it("publishes a batch at once and validates only its final state", () => { - const registry = createRegistry(); - const old = registry.tools.add(tool("grep")); - const replacement = registry.batch(() => { - registry.tools.add(tool("grep", { description: "v2" })); - registry.tools.add(tool("find")); - old.dispose(); - }); - expect(registry.tools.list().map((entry) => [entry.name, entry.description])).toEqual([ - ["grep", "v2"], - ["find", "find tool"], - ]); - replacement.dispose(); - expect(registry.tools.list()).toEqual([]); - }); - - it("rolls back staged registrations and disposals when a batch fails", () => { - const registry = createRegistry(); - const kept = registry.tools.add(tool("kept")); - let staged: ReturnType | undefined; - expect(() => - registry.batch(() => { - kept.dispose(); - staged = registry.tools.add(tool("new")); - throw new Error("activation failed"); - }), - ).toThrow("activation failed"); - expect(names(registry.tools.list())).toEqual(["kept"]); - staged!.dispose(); - expect(names(registry.tools.list())).toEqual(["kept"]); - - expect(() => - registry.batch(() => { - registry.tools.add(tool("dup")); - registry.tools.add(tool("dup")); - }), - ).toThrow("Tool dup is already registered"); - expect(names(registry.tools.list())).toEqual(["kept"]); - - expect(() => - registry.batch((async () => { - registry.tools.add(tool("async")); - }) as unknown as () => void), - ).toThrow("must be synchronous"); - expect(names(registry.tools.list())).toEqual(["kept"]); - kept.dispose(); - expect(registry.tools.list()).toEqual([]); - }); - - it("rejects nested batches as a programming error", () => { - const registry = createRegistry(); - expect(() => - registry.batch(() => { - registry.tools.add(tool("a")); - registry.batch(() => {}); - }), - ).toThrow("Registry batches cannot be nested"); - expect(registry.tools.list()).toEqual([]); - }); - - it("keeps old snapshots immutable while new ones see replacements", () => { - const registry = createRegistry(); - const registration = registry.tools.add(tool("read")); + const listener: string[] = []; + registry.subscribe(() => listener.push(names(registry.snapshot().installed()).join(","))); + const a = defineExtension({ name: "a", tools: [tool("read", { snippet: "Read files" })] }); + const b = defineExtension({ name: "b", tools: [tool("read"), tool("bash")] }); + registry.install(a); + registry.install(b); const before = registry.snapshot(); - registration.dispose(); - expect(before.tool("read")?.name).toBe("read"); - expect(registry.snapshot().tool("read")).toBeUndefined(); - }); - - it("covers only the batch's surviving registrations with its token", () => { - const registry = createRegistry(); - const outside = registry.tools.add(tool("outside")); - const batch = registry.batch(() => { - const staged = registry.tools.add(tool("staged")); - registry.tools.add(tool("kept")); - staged.dispose(); - }); - expect(names(registry.tools.list())).toEqual(["outside", "kept"]); - batch.dispose(); - expect(names(registry.tools.list())).toEqual(["outside"]); - outside.dispose(); - }); - - it("composes tool wrappers per snapshot against the current base and fails closed", () => { - const registry = createRegistry(); - registry.tools.wrap("read", "audit", (base) => ({ ...base, description: `${base.description} +audit` })); - expect(registry.tools.list()).toEqual([]); - const base = registry.tools.add(tool("read")); - registry.tools.wrap("read", "limit", (inner) => ({ ...inner, description: `${inner.description} +limit` })); - expect(registry.tools.list()[0]!.description).toBe("read tool +audit +limit"); - - registry.batch(() => { - base.dispose(); - registry.tools.add(tool("read", { description: "read v2" })); - }); - expect(registry.tools.list()[0]!.description).toBe("read v2 +audit +limit"); - - const failure = new Error("wrapper failed"); - const broken = registry.tools.wrap("read", "broken", () => { - throw failure; - }); - const snapshot = registry.snapshot(); - expect(snapshot.tools()).toEqual([]); - expect(snapshot.tool("read")).toBeUndefined(); - expect(snapshot.toolNames()).toEqual(["read"]); - expect(snapshot.failures()).toEqual([{ kind: "tool", name: "read", error: failure }]); - - broken.dispose(); - // Identity is checked after every wrapper, so a later wrapper cannot restore it. - registry.tools.wrap("read", "rename", (inner) => ({ ...inner, name: "other" })); - registry.tools.wrap("read", "restore", (inner) => ({ ...inner, name: "read" })); - expect(registry.tools.list()).toEqual([]); - }); - - it("validates section keys and composes section wrappers", () => { - const registry = createRegistry(); - expect(() => registry.systemPrompt.section("1", () => "x")).toThrow("must match"); - expect(() => registry.systemPrompt.section("Upper", () => "x")).toThrow("must match"); - registry.systemPrompt.section("preamble", () => "x"); - expect(() => registry.systemPrompt.section("preamble", () => "y")).toThrow( - "Section preamble is already registered", - ); - - const wrapped = registry.systemPrompt.section("cwd", () => "/repo", { tag: false }); - registry.systemPrompt.wrap("cwd", "upper", (section) => ({ ...section, tag: true })); - expect(registry.systemPrompt.sections().map((section) => [section.key, section.tag])).toEqual([ - ["preamble", undefined], - ["cwd", true], - ]); - wrapped.dispose(); - expect(registry.systemPrompt.sections().map((section) => section.key)).toEqual(["preamble"]); - }); - - it("stores tasks and returns typed hooks by task name", () => { - const registry = createRegistry(); - const worker = task("worker"); - registry.tasks.add(worker); - expect(() => registry.tasks.add(task("worker"))).toThrow("Task worker is already registered"); - expect(registry.tasks.list()).toEqual([GenerationTask, ToolTask, CompactionTask, worker]); - - const first = { beforeRun: () => {} }; - const second = { beforeRun: () => {} }; - const keyed = registry.hooks.add(worker, first, { key: "audit" }); - registry.hooks.add(worker, second, { scope: { conversationId: 5 as never, subtree: true } }); - expect(() => registry.hooks.add(worker, first, { key: "audit" })).toThrow("already registered"); - registry.batch(() => { - keyed.dispose(); - registry.hooks.add(task("worker"), first, { key: "audit" }); - }); - const snapshot = registry.snapshot(); - expect(snapshot.task("worker")).toBe(worker); - // A reloaded definition with the same name still finds hooks registered against the old token. - const hooks = snapshot.hooks(task("worker")); - expect(hooks.map((hook) => hook.handlers)).toEqual([first, second]); - hooks[0]!.handlers.beforeRun?.(); - expect(hooks[1]!.scope).toEqual({ conversationId: 5, subtree: true }); - }); - - it("starts with undisposable, non-overridable built-in tasks", () => { - const registry = createRegistry(); - expect(registry.tasks.list()).toEqual([GenerationTask, ToolTask, CompactionTask]); - expect(registry.snapshot().task("pi.generation")).toBe(GenerationTask); - expect(() => registry.tasks.add({ definition: { ...GenerationTask.definition } })).toThrow( - "Task pi.generation is already registered", - ); + // A new object with an installed name replaces it at its position. + const a2 = defineExtension({ name: "a", tools: [tool("grep")] }); + registry.install(a2); + expect(names(registry.snapshot().installed())).toEqual(["a", "b"]); + expect(registry.snapshot().extension("a")).toBe(a2); expect( registry .snapshot() - .conversationSetups() - .map(({ key }) => key), - ).toEqual(["pi"]); - expect(() => registry.conversations.setup("pi", () => {})).toThrow("Setup pi is already registered"); + .tools() + .map(({ extension, tool }) => `${tool.name}@${extension.name}`), + ).toEqual(["grep@a", "read@b", "bash@b"]); + // Old snapshots stay as they were. + expect(before.extension("a")).toBe(a); + expect(before.tools()[0]!.tool.snippet).toBe("Read files"); + // Uninstall matches the name, whichever object; a later install appends. + registry.uninstall(a); + registry.uninstall(a); + expect(names(registry.snapshot().installed())).toEqual(["b"]); + registry.install(a); + expect(names(registry.snapshot().installed())).toEqual(["b", "a"]); + expect(listener).toEqual(["a", "a,b", "a,b", "b", "b,a"]); }); - it("keeps application tool fields on listed tools", () => { - const execute = async (args: JsonValue) => ({ details: args }); - const registry = createRegistry(); - registry.tools.add(tool("echo", { execute, snippet: "Echo" })); - expect(registry.tools.list()[0]!.execute).toBe(execute); + it("validates the registry as it would be after an install and publishes nothing when invalid", () => { + const registry = createRegistry(); + const Tasks = defineExtension({ name: "tasks", tasks: [task("app.index")] }); + registry.install(Tasks); + const published: number[] = []; + registry.subscribe(() => published.push(1)); + const before = registry.snapshot(); + const invalid = [ + [defineExtension({ name: "x", tools: [tool("read"), tool("read")] }), "two tools named read"], + [defineExtension({ name: "x", sections: [section("a", () => "1"), section("a", () => "2")] }), "two sections"], + [defineExtension({ name: "x", sections: [section("Bad Key", () => "")] }), "must match"], + [defineExtension({ name: "x", sections: [section("instructions", () => "")] }), "reserved"], + [defineExtension({ name: "x", tasks: [task("pi.generation")] }), "already installed"], + [defineExtension({ name: "x", tasks: [task("app.index")] }), "already installed"], + ] as const; + for (const [extension, message] of invalid) expect(() => registry.install(extension)).toThrow(message); + expect(registry.snapshot()).toBe(before); + expect(published).toEqual([]); + // Replacing the extension that holds a task name is valid: the check runs on the state after replacement. + registry.install(defineExtension({ name: "tasks", tasks: [task("app.index", 2)] })); + expect(registry.snapshot().task("app.index")?.definition.version).toBe(2); + }); + + it("always holds the built-in tasks, which are not an extension", () => { + const registry = createRegistry(); + expect(registry.snapshot().installed()).toEqual([]); + expect(registry.snapshot().tasks()).toEqual([GenerationTask, ToolTask, CompactionTask]); + const custom = task("app.custom"); + registry.install(defineExtension({ name: "custom", tasks: [custom] })); + expect(registry.snapshot().tasks()).toEqual([GenerationTask, ToolTask, CompactionTask, custom]); + expect(registry.snapshot().task("app.custom")).toBe(custom); + registry.uninstall(defineExtension({ name: "custom" })); + expect(registry.snapshot().task("app.custom")).toBeUndefined(); + }); +}); + +describe("agent resolution", () => { + const read = tool("read"); + const bash = tool("bash"); + const edit = tool("edit"); + const Coding = defineExtension({ + name: "coding", + tools: [read, bash, edit], + sections: [section("preamble", () => "You code.", { tag: false }), section("cwd", () => "/repo")], + }); + const Skills = defineExtension({ name: "skills", sections: [section("skills", () => "S")] }); + const Reviewer = defineExtension({ name: "reviewer", sections: [section("role", () => "Review.")] }); + const registry = createRegistry(); + for (const extension of [Coding, Skills, Reviewer]) registry.install(extension); + const snapshot = registry.snapshot(); + + it("selects the default, an array, or the default edited by add and remove", () => { + expect(names(resolve(undefined, snapshot).extensions)).toEqual(["coding", "skills", "reviewer"]); + const settings = { extensions: [Coding, Skills] }; + expect(names(resolve({}, snapshot, settings).extensions)).toEqual(["coding", "skills"]); + expect(names(resolve({ extensions: ["reviewer", "coding"] }, snapshot, settings).extensions)).toEqual([ + "reviewer", + "coding", + ]); + // Add appends, remove drops, duplicates keep their first position, uninstalled names are skipped. + const edited = { extensions: { add: ["reviewer", "coding", "gone"], remove: ["skills"] } }; + expect(names(resolve(edited, snapshot, settings).extensions)).toEqual(["coding", "reviewer"]); + // An old object stands for its name: the installed extension is selected. + const stale = { extensions: [defineExtension({ name: "skills" }), Skills] }; + expect(resolve({}, snapshot, stale).extensions).toEqual([Skills]); + }); + + it("skips uninstalled names and resolves them again once they are installed", () => { + const local = createRegistry(); + local.install(Coding); + const state: AgentState = { extensions: ["coding", "skills"] }; + expect(names(resolve(state, local.snapshot()).extensions)).toEqual(["coding"]); + local.install(Skills); + expect(names(resolve(state, local.snapshot()).extensions)).toEqual(["coding", "skills"]); + }); + + it("replaces same-name tools in place, wraps the winner, then applies the filter", () => { + const local = createRegistry(); + const venvBash = tool("bash", { description: "venv bash" }); + const calls: string[] = []; + const Venv = defineExtension({ name: "venv", tools: [venvBash] }); + const Timing = defineExtension({ + name: "timing", + wraps: [ + wrapTool(bash, (inner) => ({ ...inner, description: `${inner.description} (timed)` })), + wrapTool(bash, (inner) => ({ ...inner, description: `${inner.description} [2]` })), + // No `grep` is selected: the wrapper does nothing and reports nothing. + wrapTool(tool("grep"), () => { + calls.push("grep"); + return tool("grep"); + }), + ], + }); + for (const extension of [Coding, Venv, Timing]) local.install(extension); + const reports: unknown[] = []; + const agent = resolve(undefined, local.snapshot(), {}, reports); + expect(agent.tools.map((each) => [each.name, each.description])).toEqual([ + ["read", "read tool"], + ["bash", "venv bash (timed) [2]"], + ["edit", "edit tool"], + ]); + expect(reports).toEqual([]); + expect(calls).toEqual([]); + + // An array keeps exactly these names in its order, a repeated name at its first position. + const filtered = resolve({ tools: ["edit", "missing", "read", "edit"] }, local.snapshot()); + expect(names(filtered.tools)).toEqual(["edit", "read"]); + expect(names(resolve({ tools: { remove: ["bash"] } }, local.snapshot()).tools)).toEqual(["read", "edit"]); + }); + + it("drops a tool or section whose wrapper throws or renames it and reports the failure", () => { + const local = createRegistry(); + const Broken = defineExtension({ + name: "broken", + wraps: [ + wrapTool(read, () => { + throw new Error("wrapper failed"); + }), + wrapTool(edit, (inner) => ({ ...inner, name: "renamed" })), + wrapSection("cwd", () => { + throw new Error("section wrapper failed"); + }), + ], + }); + local.install(Coding); + local.install(Broken); + const reports: unknown[] = []; + const agent = resolve(undefined, local.snapshot(), {}, reports); + expect(names(agent.tools)).toEqual(["bash"]); + expect(agent.sections.map((each) => each.key)).toEqual(["preamble"]); + expect(reports.map((error) => (error as Error).message)).toEqual([ + "wrapper failed", + "Wrapper renamed edit to renamed", + "section wrapper failed", + ]); + }); + + it("orders sections by extension, replaces same keys in place, and renders instructions last and unwrapped", async () => { + const local = createRegistry(); + const Override = defineExtension({ + name: "override", + sections: [section("preamble", () => "You review.", { tag: false })], + wraps: [ + wrapSection("cwd", (inner) => ({ + ...inner, + render: async (input, ctx) => `${await inner.render(input, ctx)}!`, + })), + // Instructions are not wrapped. + wrapSection("instructions", () => { + throw new Error("never"); + }), + ], + }); + for (const extension of [Coding, Skills, Override]) local.install(extension); + const reports: unknown[] = []; + const agent = resolve({ instructions: "Be terse." }, local.snapshot(), {}, reports); + expect(await rendered(agent)).toEqual([ + ["preamble", "You review."], + ["cwd", "/repo!"], + ["skills", "S"], + ["instructions", "Be terse."], + ]); + expect(agent.sections.at(-1)!.tag).toBeUndefined(); + expect(reports).toEqual([]); + }); + + it("collects hooks of the selected extensions in extension order and applies field defaults", () => { + const local = createRegistry(); + const first = { beforeTool: () => undefined }; + const second = { beforeTool: () => undefined }; + const onYield = { onYield: () => undefined }; + local.install( + defineExtension({ name: "a", hooks: [hook(ToolTask, first), hook(GenerationTask, onYield)] }), + ); + local.install(defineExtension({ name: "b", hooks: [hook(ToolTask, second)] })); + expect(agentHooks(resolve(undefined, local.snapshot()), "pi.tool")).toEqual([first, second]); + expect(agentHooks(resolve({ extensions: ["b", "a"] }, local.snapshot()), "pi.tool")).toEqual([second, first]); + expect(agentHooks(resolve({ extensions: ["b"] }, local.snapshot()), "pi.generation")).toEqual([]); + + const defaults = resolve(undefined, local.snapshot()); + expect(defaults.model).toBeUndefined(); + expect(defaults.thinkingLevel).toBe("off"); + expect(defaults.cwd).toBeUndefined(); + expect( + resolve({ model: { provider: "p", modelId: "m" }, thinkingLevel: "high", cwd: "/w" }, local.snapshot()), + ).toMatchObject({ model: { provider: "p", modelId: "m" }, thinkingLevel: "high", cwd: "/w" }); }); }); diff --git a/packages/durable/test/harness-structured.test.ts b/packages/durable/test/harness-structured.test.ts index c7e6d8686..f061e1055 100644 --- a/packages/durable/test/harness-structured.test.ts +++ b/packages/durable/test/harness-structured.test.ts @@ -17,6 +17,7 @@ import { createSession, defineDoc, defineTask, + defineTool, GenerationTask, type Harness, type JoinPolicy, @@ -37,6 +38,7 @@ import { import { afterEach, describe, expect, it } from "vitest"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { allEntries, chatSetup, openChat, waitFor } from "./chat-support.ts"; +import { addHooks, addTask, addTool } from "./harness-support.ts"; import { context } from "./session-support.ts"; import { aborted, type Deferred, deferred, openTasks, settled } from "./task-support.ts"; @@ -987,7 +989,7 @@ function blockingTool(name: string): { started: Promise; registration: Too const started = deferred(); return { started: started.promise, - registration: { + registration: defineTool({ name, description: `The ${name} tool`, parameters: Type.Object({}), @@ -997,7 +999,7 @@ function blockingTool(name: string): { started: Promise; registration: Too started.resolve(); return aborted(callContext.abortSignal!); }, - }, + }), }; } @@ -1011,12 +1013,15 @@ function toolCalls(...ids: readonly [string, string][]) { describe("tool rounds", () => { it("owns its tool tasks, waits for them, and hands the run to a conversation-owned generation", async () => { const setup = chatSetup(); - setup.registry.tools.add({ - name: "noop", - description: "Does nothing", - parameters: Type.Object({}), - execute: async () => ({ content: [] }), - }); + addTool( + setup.registry, + defineTool({ + name: "noop", + description: "Does nothing", + parameters: Type.Object({}), + execute: async () => ({ content: [] }), + }), + ); setup.faux.setResponses([toolCalls(["noop", "c1"], ["noop", "c2"]), fauxAssistantMessage([fauxText("done")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); expect((await (await root.submit({ type: "input", content: "go" }, context)).wait(context)).status).toBe("done"); @@ -1037,8 +1042,8 @@ describe("tool rounds", () => { const setup = chatSetup(); const one = blockingTool("one"); const two = blockingTool("two"); - setup.registry.tools.add({ ...one.registration, executionMode: "parallel" }); - setup.registry.tools.add({ ...two.registration, executionMode: "parallel" }); + addTool(setup.registry, defineTool({ ...one.registration, executionMode: "parallel" })); + addTool(setup.registry, defineTool({ ...two.registration, executionMode: "parallel" })); setup.faux.setResponses([toolCalls(["one", "c1"], ["two", "c2"])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const submission = await root.submit({ type: "input", content: "go" }, context); @@ -1056,8 +1061,8 @@ describe("tool rounds", () => { it("answers the unstarted calls of an aborted sequential round with aborted results, in call order", async () => { const setup = chatSetup(); const one = blockingTool("one"); - setup.registry.tools.add(one.registration); - setup.registry.tools.add({ ...blockingTool("two").registration, name: "two" }); + addTool(setup.registry, one.registration); + addTool(setup.registry, defineTool({ ...blockingTool("two").registration, name: "two" })); setup.faux.setResponses([toolCalls(["one", "c1"], ["two", "c2"], ["two", "c3"])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const submission = await root.submit({ type: "input", content: "go" }, context); @@ -1085,16 +1090,19 @@ describe("tool rounds", () => { it("ends a turn at the generation's hold, before its successor's turn starts", async () => { const setup = chatSetup(); - setup.registry.tasks.add(Node); - setup.registry.tools.add({ - name: "noop", - description: "Does nothing", - parameters: Type.Object({}), - execute: async () => ({ content: [] }), - }); + addTask(setup.registry, Node); + addTool( + setup.registry, + defineTool({ + name: "noop", + description: "Does nothing", + parameters: Type.Object({}), + execute: async () => ({ content: [] }), + }), + ); // An extension's hook starts work owned by the generation, which holds it while the next turn runs. let opened: Harness | undefined; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { afterTools: async (_assistant, _results, api, callContext) => { await opened!.commit((tx) => tx.createTask(Node, { name: "hooked" }, owned(api.taskId)), callContext); }, @@ -1130,9 +1138,9 @@ describe("tool rounds", () => { }, }); const setup = { ...base, models }; - setup.registry.tasks.add(Node); + addTask(setup.registry, Node); let opened: Harness | undefined; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { beforeRequest: async (_request, api, callContext) => { await opened!.commit((tx) => tx.createTask(Node, { name: "hooked" }, owned(api.taskId)), callContext); return undefined; @@ -1250,7 +1258,7 @@ function spawnAndFinish(name: string, child: string): { id?: TaskId } { describe("definitions and waits", () => { it("keeps a waiting task blocked without its definition and resumes it migrated under a newer one", async () => { const { harness, root, registry } = await openNodes(); - const registration = registry.tasks.add(versioned(1)); + const registration = addTask(registry, versioned(1)); const other = await start(root, "other"); const waiter = await root.commit((tx) => tx.createTask(versioned(1), { on: [other] }, OWN_CONVERSATION), context); await until(async () => (await state(harness, waiter)).status === "waiting"); @@ -1261,14 +1269,14 @@ describe("definitions and waits", () => { (await harness.inspect(context)).tasks.find((task) => task.record.id === waiter)?.state; expect(await inspected()).toEqual({ kind: "blocked", reason: "missing_task" }); expect((await state(harness, waiter)).status).toBe("waiting"); - registry.tasks.add(versioned(2)); + addTask(registry, versioned(2)); expect((await harness.waitForTask(waiter, context)).state.outcome).toEqual({ status: "completed", result: "v2" }); await harness.close(context); }); it("orphans an aborted waiting task without its definition at once, leaving the task it waits on running", async () => { const { harness, root, registry } = await openNodes(); - const registration = registry.tasks.add(versioned(1)); + const registration = addTask(registry, versioned(1)); const other = await start(root, "other"); const waiter = await root.commit((tx) => tx.createTask(versioned(1), { on: [other] }, OWN_CONVERSATION), context); await until(async () => (await state(harness, waiter)).status === "waiting"); @@ -1305,13 +1313,11 @@ describe("definitions and waits", () => { throw new Error("never migrates"); }, }); - const first = registry.tasks.add(holder(1)); + addTask(registry, holder(1)); const parent = await root.commit((tx) => tx.createTask(holder(1), null, OWN_CONVERSATION), context); await until(async () => (await state(harness, parent)).status === "completing"); - registry.batch(() => { - first.dispose(); - registry.tasks.add(holder(2)); - }); + // The same extension name replaces the old one in place. + addTask(registry, holder(2)); open("child"); const settledParent = await harness.waitForTask(parent, context); expect(settledParent.state.outcome).toEqual({ status: "completed", result: "held" }); @@ -1568,8 +1574,8 @@ describe("tool rounds and events", () => { it("ends an aborted sequential round's unstarted calls with their result entries, right before them", async () => { const setup = chatSetup(); const one = blockingTool("one"); - setup.registry.tools.add(one.registration); - setup.registry.tools.add({ ...blockingTool("two").registration, name: "two" }); + addTool(setup.registry, one.registration); + addTool(setup.registry, defineTool({ ...blockingTool("two").registration, name: "two" })); setup.faux.setResponses([toolCalls(["one", "c1"], ["two", "c2"], ["two", "c3"])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const { stream, events } = await listen(harness, root); @@ -1592,15 +1598,18 @@ describe("tool rounds and events", () => { it("emits one turn_end per generation, also for a stream attached while it holds", async () => { const setup = chatSetup(); - setup.registry.tasks.add(Node); - setup.registry.tools.add({ - name: "noop", - description: "Does nothing", - parameters: Type.Object({}), - execute: async () => ({ content: [] }), - }); + addTask(setup.registry, Node); + addTool( + setup.registry, + defineTool({ + name: "noop", + description: "Does nothing", + parameters: Type.Object({}), + execute: async () => ({ content: [] }), + }), + ); let opened: Harness | undefined; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { afterTools: async (_assistant, _results, api, callContext) => { await opened!.commit((tx) => tx.createTask(Node, { name: "hooked" }, owned(api.taskId)), callContext); }, @@ -1626,19 +1635,22 @@ describe("tool rounds and events", () => { it("lets a tool that owns live work finish its call at the hold while the generation waits for its final commit", async () => { const setup = chatSetup(); - setup.registry.tasks.add(Node); - setup.registry.tools.add({ - name: "delegate", - description: "Starts work in a conversation it owns", - parameters: Type.Object({}), - execute: async (_args, api, callContext) => { - await api.commit(async (tx) => { - const child = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); - await tx.createTask(Node, { name: "sub" }, { ...OWN_CONVERSATION, conversationId: child.id }); - }, callContext); - return { content: [{ type: "text", text: "started" }] }; - }, - }); + addTask(setup.registry, Node); + addTool( + setup.registry, + defineTool({ + name: "delegate", + description: "Starts work in a conversation it owns", + parameters: Type.Object({}), + execute: async (_args, api, callContext) => { + await api.commit(async (tx) => { + const child = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } }); + await tx.createTask(Node, { name: "sub" }, { ...OWN_CONVERSATION, conversationId: child.id }); + }, callContext); + return { content: [{ type: "text", text: "started" }] }; + }, + }), + ); setup.faux.setResponses([toolCalls(["delegate", "c1"]), fauxAssistantMessage([fauxText("done")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const { stream, events } = await listen(harness, root); @@ -1660,22 +1672,25 @@ describe("tool rounds and events", () => { it("holds a faulted tool's slot and task_failed until the work it owns drained", async () => { const setup = chatSetup(); - setup.registry.tasks.add(Node); + addTask(setup.registry, Node); script("held", { abort: async (runtime, ctx) => { await gate("abort.held").promise; await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), ctx); }, }); - setup.registry.tools.add({ - name: "broken", - description: "Starts owned work, then returns a result that is not strict JSON", - parameters: Type.Object({}), - execute: async (_args, api, callContext) => { - await api.commit((tx) => tx.createTask(Node, { name: "held" }, owned(api.taskId)), callContext); - return { content: [], details: { fn: (() => 1) as unknown as JsonValue } }; - }, - }); + addTool( + setup.registry, + defineTool({ + name: "broken", + description: "Starts owned work, then returns a result that is not strict JSON", + parameters: Type.Object({}), + execute: async (_args, api, callContext) => { + await api.commit((tx) => tx.createTask(Node, { name: "held" }, owned(api.taskId)), callContext); + return { content: [], details: { fn: (() => 1) as unknown as JsonValue } }; + }, + }), + ); setup.faux.setResponses([toolCalls(["broken", "c1"]), fauxAssistantMessage([fauxText("done")])]); const { harness, root } = await openChat(new MemoryStorage(), setup); const { stream, events } = await listen(harness, root); diff --git a/packages/durable/test/harness-support.ts b/packages/durable/test/harness-support.ts index b661a1773..fc04a3b49 100644 --- a/packages/durable/test/harness-support.ts +++ b/packages/durable/test/harness-support.ts @@ -9,16 +9,24 @@ import type { } from "@earendil-works/pi-ai"; import { createModels, Type } from "@earendil-works/pi-ai"; import { + type AnyTask, createRegistry, + defineExtension, + defineTool, + type Extension, Harness, + type HooksOf, + hook, + type PromptSection, type Registry, type Storage, + section, type ToolRegistration, } from "@earendil-works/pi-durable"; import { context } from "./session-support.ts"; export function tool(name: string, description = `${name} tool`): ToolRegistration { - return { name, description, parameters: Type.Object({}), execute: async () => ({ content: [] }) }; + return defineTool({ name, description, parameters: Type.Object({}), execute: async () => ({ content: [] }) }); } /** Open a Harness with a fresh registry holding the named tools. */ @@ -28,7 +36,8 @@ export async function openHarness( options: { readonly registry?: Registry; readonly onReport?: (error: unknown) => void } = {}, ): Promise<{ readonly harness: Harness; readonly registry: Registry }> { const registry = options.registry ?? createRegistry(); - for (const name of toolNames) registry.tools.add(tool(name)); + if (toolNames.length > 0) + registry.install(defineExtension({ name: "tools", tools: toolNames.map((name) => tool(name)) })); const harness = await Harness.open( storage, { models: createModels(), registry, ...(options.onReport === undefined ? {} : { onReport: options.onReport }) }, @@ -102,3 +111,48 @@ export function describeMessage(message: Message): string { return `system:${Object.keys(message.sections ?? {}).join(",")}`; } } + +/** Uninstalls what one of the helpers below installed. */ +export type Installed = { dispose(): void }; + +function installOne(registry: Registry, extension: Extension): Installed { + registry.install(extension); + return { dispose: () => registry.uninstall(extension) }; +} + +/** Install a one-tool extension named after the tool. */ +export function addTool( + registry: Registry, + tool: Tool, + name = `tool:${tool.name}`, +): Installed { + return installOne(registry, defineExtension({ name, tools: [tool] })); +} + +/** Install a one-task extension named after the task. */ +export function addTask(registry: Registry, task: AnyTask, name = `task:${task.definition.name}`): Installed { + return installOne(registry, defineExtension({ name, tasks: [task] })); +} + +let hookExtensions = 0; + +/** Install an extension with one hook registration for `task`. */ +export function addHooks( + registry: Registry, + task: K, + handlers: Partial>, + name = `hooks:${++hookExtensions}`, +): Installed { + return installOne(registry, defineExtension({ name, hooks: [hook(task, handlers)] })); +} + +/** Install a one-section extension named after the section. */ +export function addSection( + registry: Registry, + key: string, + render: PromptSection["render"], + options?: { readonly tag?: boolean }, + name = `section:${key}`, +): Installed { + return installOne(registry, defineExtension({ name, sections: [section(key, render, options)] })); +} diff --git a/packages/durable/test/harness-tasks-recovery.test.ts b/packages/durable/test/harness-tasks-recovery.test.ts index 69a449238..574e5679e 100644 --- a/packages/durable/test/harness-tasks-recovery.test.ts +++ b/packages/durable/test/harness-tasks-recovery.test.ts @@ -2,11 +2,12 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Context, JsonValue } from "@earendil-works/chord"; -import { createModels } from "@earendil-works/pi-ai"; +import { createModels, Type } from "@earendil-works/pi-ai"; import { createRegistry, defineDoc, defineTask, + defineTool, Harness, MemoryStorage, type Registry, @@ -16,6 +17,7 @@ import { } from "@earendil-works/pi-durable"; import { afterEach, describe, expect, it } from "vitest"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; +import { addTask, addTool } from "./harness-support.ts"; import { ControlledStorage, context, flush } from "./session-support.ts"; import { aborted, abortedWith, completed, countingReader, deferred, eventually, openTasks } from "./task-support.ts"; @@ -404,7 +406,7 @@ describe("blocked tasks", () => { expect((await harness.getTask(id, context))?.state.status).toBe("pending"); const idle = harness.waitForIdle(context); await flush(); - registry.tasks.add(V1); + addTask(registry, V1); await idle; expect((await harness.getTask(id, context))?.state).toEqual({ status: "terminal", @@ -415,16 +417,14 @@ describe("blocked tasks", () => { it("keeps a task stored by a newer version pending until a fitting definition is registered", async () => { const registry = createRegistry(); - const old = registry.tasks.add(versioned(1, "old")); + addTask(registry, versioned(1, "old")); const { harness } = await openTasks(new MemoryStorage(), [], { registry }); const id = await createIn(harness, versioned(2, "new")); harness.resume(); await flush(); expect((await harness.getTask(id, context))?.state.status).toBe("pending"); - registry.batch(() => { - old.dispose(); - registry.tasks.add(versioned(2, "new")); - }); + // The same extension name replaces the old one in place. + addTask(registry, versioned(2, "new")); expect((await harness.waitForTask(id, context)).state.outcome).toEqual({ status: "completed", result: "new" }); await harness.close(context); }); @@ -437,7 +437,8 @@ describe("blocked tasks", () => { const registry: Registry = createRegistry(); let failures = 0; - const failing = registry.tasks.add( + addTask( + registry, versioned(2, "v2", () => { failures++; throw new Error("cannot migrate"); @@ -447,12 +448,15 @@ describe("blocked tasks", () => { opened.harness.resume(); await eventually(() => opened.reports.length === 1); // An unrelated registry change wakes the scheduler without retrying the same failed definition. - registry.tools.add({ - name: "unrelated", - description: "unrelated", - parameters: { type: "object", properties: {} } as never, - execute: async () => ({}), - }); + addTool( + registry, + defineTool({ + name: "unrelated", + description: "unrelated", + parameters: Type.Object({}), + execute: async () => ({}), + }), + ); await flush(); expect(failures).toBe(1); expect(opened.reports).toHaveLength(1); @@ -463,15 +467,14 @@ describe("blocked tasks", () => { }); const migrations: number[] = []; - registry.batch(() => { - failing.dispose(); - registry.tasks.add( - versioned(2, "v2", (_input, checkpoint, fromVersion) => { - migrations.push(fromVersion); - return { input: null, checkpoint: checkpoint as Step }; - }), - ); - }); + // The same extension name replaces the old one in place. + addTask( + registry, + versioned(2, "v2", (_input, checkpoint, fromVersion) => { + migrations.push(fromVersion); + return { input: null, checkpoint: checkpoint as Step }; + }), + ); const receipt = await opened.harness.waitForTask(id, context); expect(receipt).toMatchObject({ version: 2, state: { outcome: { status: "completed", result: "v2" } } }); expect(migrations).toEqual([1]); @@ -532,7 +535,7 @@ describe("blocked tasks", () => { }, }); const registry = createRegistry(); - const registration = registry.tasks.add(Running); + const registration = addTask(registry, Running); const { harness } = await openTasks(new MemoryStorage(), [], { registry }); const id = await createIn(harness, Running); harness.resume(); @@ -613,7 +616,7 @@ describe("definition handover", () => { async function startHandover(log: string[], gates: Gates, storage = new MemoryStorage()) { const registry = createRegistry(); - const old = registry.tasks.add(handoverTask("old", 1, log, { gates })); + const old = addTask(registry, handoverTask("old", 1, log, { gates })); const opened = await openTasks(storage, [], { registry }); const id = await createIn(opened.harness, handoverTask("old", 1, log)); opened.harness.resume(); @@ -624,11 +627,9 @@ describe("definition handover", () => { it("hands over at the next phase boundary to a same-version replacement without overlap", async () => { const log: string[] = []; const gate = deferred(); - const { harness, registry, old, id } = await startHandover(log, { a: gate.promise }); - registry.batch(() => { - old.dispose(); - registry.tasks.add(handoverTask("new", 1, log)); - }); + const { harness, registry, id } = await startHandover(log, { a: gate.promise }); + // The same extension name replaces the old one in place. + addTask(registry, handoverTask("new", 1, log)); gate.resolve(); await harness.waitForTask(id, context); expect(log).toEqual(["old:a start", "old:a end", "new:b start", "new:b end", "new:c"]); @@ -638,13 +639,9 @@ describe("definition handover", () => { it("hands over to a newer version with a migration", async () => { const log: string[] = []; const gate = deferred(); - const { harness, registry, old, id } = await startHandover(log, { a: gate.promise }); - registry.batch(() => { - old.dispose(); - registry.tasks.add( - handoverTask("v2", 2, log, { migrate: () => ({ input: null, checkpoint: { phase: "c" } }) }), - ); - }); + const { harness, registry, id } = await startHandover(log, { a: gate.promise }); + // The same extension name replaces the old one in place. + addTask(registry, handoverTask("v2", 2, log, { migrate: () => ({ input: null, checkpoint: { phase: "c" } }) })); gate.resolve(); const receipt = await harness.waitForTask(id, context); expect(receipt.version).toBe(2); @@ -655,17 +652,16 @@ describe("definition handover", () => { it("hands over to a newer version whose migration fails and leaves the task blocked", async () => { const log: string[] = []; const gate = deferred(); - const { harness, registry, old, id, reports } = await startHandover(log, { a: gate.promise }); - registry.batch(() => { - old.dispose(); - registry.tasks.add( - handoverTask("broken", 2, log, { - migrate: () => { - throw new Error("broken migration"); - }, - }), - ); - }); + const { harness, registry, id, reports } = await startHandover(log, { a: gate.promise }); + // The same extension name replaces the old one in place. + addTask( + registry, + handoverTask("broken", 2, log, { + migrate: () => { + throw new Error("broken migration"); + }, + }), + ); gate.resolve(); await eventually(() => reports.length === 1); await flush(); @@ -686,7 +682,7 @@ describe("definition handover", () => { gateA.resolve(); await eventually(() => log.includes("old:b start")); // A newer definition without a migration cannot take the task either. - registry.tasks.add(handoverTask("incompatible", 2, log)); + addTask(registry, handoverTask("incompatible", 2, log)); gateB.resolve(); await harness.waitForTask(id, context); expect(log).toEqual(["old:a start", "old:a end", "old:b start", "old:b end", "old:c"]); @@ -722,16 +718,14 @@ describe("definition handover", () => { }, abort: async () => {}, }); - const old = registry.tasks.add(Old); + addTask(registry, Old); const { harness } = await openTasks(storage, [], { registry }); harnessRef = harness; const id = await createIn(harness, Old); harness.resume(); await eventually(() => oldRuntime !== undefined); - registry.batch(() => { - old.dispose(); - registry.tasks.add(handoverTask("new", 1, log)); - }); + // The same extension name replaces the old one in place. + addTask(registry, handoverTask("new", 1, log)); gate.resolve(); await eventually(() => held !== undefined); await held!.entered; @@ -757,11 +751,9 @@ describe("definition handover", () => { held ??= storage.holdCommits(); }, }; - const { harness, registry, old, id } = await startHandover(log, gates, storage); - registry.batch(() => { - old.dispose(); - registry.tasks.add(handoverTask("new", 1, log)); - }); + const { harness, registry, id } = await startHandover(log, gates, storage); + // The same extension name replaces the old one in place. + addTask(registry, handoverTask("new", 1, log)); gate.resolve(); await eventually(() => held !== undefined); await held!.entered; diff --git a/packages/durable/test/harness-tasks.test.ts b/packages/durable/test/harness-tasks.test.ts index 0ff89830d..dd2f5947b 100644 --- a/packages/durable/test/harness-tasks.test.ts +++ b/packages/durable/test/harness-tasks.test.ts @@ -1,11 +1,13 @@ import type { Context } from "@earendil-works/chord"; -import { createModels } from "@earendil-works/pi-ai"; +import { withCancel } from "@earendil-works/chord/context"; +import { createModels, Type } from "@earendil-works/pi-ai"; import { type Conversation, createRegistry, defineDoc, defineEntry, defineTask, + defineTool, type EntryId, Harness, MemoryStorage, @@ -16,7 +18,7 @@ import { type TaskRuntime, } from "@earendil-works/pi-durable"; import { describe, expect, it } from "vitest"; -import { user } from "./harness-support.ts"; +import { addHooks, addTask, addTool, user } from "./harness-support.ts"; import { ControlledStorage, context, flush } from "./session-support.ts"; import { aborted, abortedWith, completed, deferred, eventually, openTasks, settled } from "./task-support.ts"; @@ -309,7 +311,7 @@ describe("task phases", () => { const seen: { phase: string; tools: string[]; same: boolean }[] = []; const phaseGate = deferred(); const entered = deferred(); - const tools = (snapshot: RegistrySnapshot) => snapshot.toolNames().slice(); + const tools = (snapshot: RegistrySnapshot) => snapshot.tools().map(({ tool }) => tool.name); const Snapshots = defineTask({ name: "test.snapshots", version: 1, @@ -336,12 +338,15 @@ describe("task phases", () => { ); harness.resume(); await entered.promise; - registry.tools.add({ - name: "late", - description: "late", - parameters: { type: "object", properties: {} } as never, - execute: async () => ({}), - }); + addTool( + registry, + defineTool({ + name: "late", + description: "late", + parameters: Type.Object({}), + execute: async () => ({}), + }), + ); phaseGate.resolve(); await harness.waitForTask(id, context); expect(seen).toEqual([ @@ -350,6 +355,117 @@ describe("task phases", () => { ]); await harness.close(context); }); + + it("resolves the agent at first use in a phase from its snapshot, keeps it for the phase, and anew at the next", async () => { + type PingHooks = { ping(): void }; + const pings: string[] = []; + const seen: { phase: string; pings: string[]; thinking: string; same?: boolean }[] = []; + const beforeUse = deferred(); + const afterUse = deferred(); + const waitingBeforeUse = deferred(); + const waitingAfterUse = deferred(); + const ping = async (runtime: TaskRuntime) => { + pings.length = 0; + await runtime.hooks.each("ping", (handler) => handler()); + return [...pings]; + }; + const Phases = defineTask({ + name: "test.agent-phases", + version: 1, + initial: () => ({ phase: "a" }), + phases: { + a: async (_task, runtime, ctx) => { + waitingBeforeUse.resolve(); + await beforeUse.promise; + const first = await runtime.agent(ctx); + seen.push({ phase: "a", pings: await ping(runtime), thinking: first.thinkingLevel }); + waitingAfterUse.resolve(); + await afterUse.promise; + const second = await runtime.agent(ctx); + seen.push({ + phase: "a", + pings: await ping(runtime), + thinking: second.thinkingLevel, + same: second === first, + }); + await runtime.commit(() => ({ status: "running", checkpoint: { phase: "b" } }), ctx); + }, + b: async (_task, runtime, ctx) => { + const agent = await runtime.agent(ctx); + seen.push({ phase: "b", pings: await ping(runtime), thinking: agent.thinkingLevel }); + await runtime.commit(() => completed(null), ctx); + }, + }, + abort: async () => {}, + }); + const { harness, registry, root } = await openRoot([Phases]); + addHooks(registry, Phases, { ping: () => void pings.push("before") }); + const id = await root.commit( + (tx) => tx.createTask(Phases, null, { ownership: { kind: "conversation" } }), + context, + ); + harness.resume(); + + // Configured during the phase but before its first use: the lazy resolution reads it. + await waitingBeforeUse.promise; + await root.configure({ thinkingLevel: "low" }, context); + beforeUse.resolve(); + // Configured and installed after the first use: not seen until the next phase. + await waitingAfterUse.promise; + addHooks(registry, Phases, { ping: () => void pings.push("late") }); + await root.configure({ thinkingLevel: "high" }, context); + afterUse.resolve(); + + await harness.waitForTask(id, context); + expect(seen).toEqual([ + { phase: "a", pings: ["before"], thinking: "low" }, + { phase: "a", pings: ["before"], thinking: "low", same: true }, + { phase: "b", pings: ["before", "late"], thinking: "high" }, + ]); + await harness.close(context); + }); + + it("rejects an agent() wait whose caller context is cancelled, without affecting the phase's resolution", async () => { + let cancelled: unknown; + let resolved: string | undefined; + const Agent = oneStep("test.agent-cancel", async (_task, runtime, ctx) => { + const caller = withCancel(ctx); + caller.cancel(new Error("caller gone")); + cancelled = await runtime.agent(caller.context).catch((error: unknown) => error); + resolved = (await runtime.agent(ctx)).thinkingLevel; + await runtime.commit(() => completed(null), ctx); + }); + const { harness, root } = await openRoot([Agent]); + await harness.waitForTask(await start(root, Agent), context); + expect(cancelled).toMatchObject({ message: "caller gone" }); + expect(resolved).toBe("off"); + await harness.close(context); + }); + + it("observes a failed agent resolution whose only caller stopped waiting", async () => { + let fail = false; + const settings = { + get extensions(): undefined { + if (fail) throw new Error("settings broke"); + return undefined; + }, + }; + let resolved: unknown; + const Agent = oneStep("test.agent-failure", async (_task, runtime, ctx) => { + const caller = withCancel(ctx); + caller.cancel(new Error("caller gone")); + fail = true; + await runtime.agent(caller.context).catch(() => {}); + // Let an unobserved rejection surface before the task ends. + await new Promise((resolve) => setTimeout(resolve, 0)); + resolved = await runtime.agent(ctx).catch((error: unknown) => error); + await runtime.commit(() => completed(null), ctx); + }); + const { harness, root } = await openRoot([Agent], { settings }); + await harness.waitForTask(await start(root, Agent), context); + expect(resolved).toMatchObject({ message: "settings broke" }); + await harness.close(context); + }); }); describe("task runtime", () => { @@ -370,8 +486,10 @@ describe("task runtime", () => { initial: () => ({ text: "" }), }); let absent: unknown = "unset"; + let handlerContext: Context | undefined; const Watcher = oneStep("test.watcher", async (_task, runtime, ctx) => { captured = runtime; + handlerContext = ctx; absent = await runtime.watchDoc(Absent, ctx); const watch = await runtime.watchDoc(Notes, ctx); watch!.start(async (value) => { @@ -401,6 +519,8 @@ describe("task runtime", () => { await expect(runtime.memo("x", 1, context)).rejects.toThrow("invocation has ended"); await expect(runtime.sleep(0, context)).rejects.toThrow("invocation has ended"); await expect(runtime.watchDoc(Notes, context)).rejects.toThrow("invocation has ended"); + // The handler's own context is cancelled by now; the ended invocation still wins. + await expect(runtime.agent(handlerContext!)).rejects.toThrow("invocation has ended"); await harness.close(context); }); @@ -584,12 +704,15 @@ describe("task scheduling", () => { await eventually(() => reports.length === 1); expect((await harness.getTask(id, context))?.state.status).toBe("pending"); // Any wakeup, here a registry change, reserves again. - registry.tools.add({ - name: "wake", - description: "wake", - parameters: { type: "object", properties: {} } as never, - execute: async () => ({}), - }); + addTool( + registry, + defineTool({ + name: "wake", + description: "wake", + parameters: Type.Object({}), + execute: async () => ({}), + }), + ); await harness.waitForTask(id, context); expect(runs).toBe(1); await harness.close(context); @@ -611,7 +734,7 @@ describe("task scheduling", () => { harness.resume(); await held.entered; // Registering the missing definition wakes the scheduler while the doomed reservation is in storage. - registry.tasks.add(Late); + addTask(registry, Late); held.release(); await harness.waitForTask(first, context); await harness.waitForTask(late, context); @@ -1183,7 +1306,7 @@ describe("task close", () => { }, abort: async () => {}, }); - registry.tasks.add(Two); + addTask(registry, Two); const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry: reader }, context); harnessRef = harness; const root = await harness.root(context); diff --git a/packages/durable/test/harness-tools-recovery.test.ts b/packages/durable/test/harness-tools-recovery.test.ts index e19a9dcd8..a03932f96 100644 --- a/packages/durable/test/harness-tools-recovery.test.ts +++ b/packages/durable/test/harness-tools-recovery.test.ts @@ -11,11 +11,11 @@ import { Type, } from "@earendil-works/pi-ai"; import { + defineTool, type EntryRecord, GenerationTask, type Harness, LiveDoc, - type Registration, type TaskId, type ToolRegistration, ToolResultEntry, @@ -27,6 +27,7 @@ import { NodeExecutionEnv } from "../src/env/node.ts"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { createBashTool } from "../src/tools/index.ts"; import { allEntries, type ChatSetup, chatSetup, openChat, waitFor } from "./chat-support.ts"; +import { addHooks, addTool, type Installed } from "./harness-support.ts"; import { context } from "./session-support.ts"; import { aborted, deferred } from "./task-support.ts"; @@ -54,7 +55,7 @@ function tool( execute: ToolRegistration["execute"], extra: Partial = {}, ): ToolRegistration { - return { name, description: name, parameters: Type.Object({}), execute, ...extra }; + return defineTool({ name, description: name, parameters: Type.Object({}), execute, ...extra }); } function call(name: string, id = "c1"): AssistantMessage { @@ -109,7 +110,7 @@ describe("tool recovery", () => { const path = await sqlitePath(); const setup = chatSetup(); const { registration, started, state } = blockingTool("work"); - setup.registry.tools.add(registration); + addTool(setup.registry, registration); setup.faux.setResponses([call("work"), DONE]); let opened = await open(path, setup); const id = (await opened.root.submit({ type: "input", content: "go" }, context)).id; @@ -142,7 +143,7 @@ describe("tool recovery", () => { const path = await sqlitePath(); const setup = chatSetup(); const blocking = blockingTool("work", { replay: stored }); - const registration: Registration = setup.registry.tools.add(blocking.registration); + const registration: Installed = addTool(setup.registry, blocking.registration); setup.faux.setResponses([call("work"), DONE]); let opened = await open(path, setup); const id = (await opened.root.submit({ type: "input", content: "go" }, context)).id; @@ -150,7 +151,7 @@ describe("tool recovery", () => { await opened.harness.close(context); registration.dispose(); - setup.registry.tools.add({ ...blocking.registration, replay: current }); + addTool(setup.registry, { ...blocking.registration, replay: current }); opened = await open(path, setup); expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done"); const [result] = results(await allEntries(opened.root)); @@ -161,11 +162,60 @@ describe("tool recovery", () => { } }); + it("reruns a safe tool with the environment of the conversation's cwd at rerun, and not once it is deselected", async () => { + for (const change of ["cwd", "deselect"] as const) { + const path = await sqlitePath(); + const setup = chatSetup(); + const cwds: (string | undefined)[] = []; + const started = deferred(); + const work = tool( + "work", + async (_args, api, callContext) => { + cwds.push(api.env?.cwd); + if (cwds.length === 1) { + started.resolve(); + await aborted(callContext.abortSignal!); + } + return {}; + }, + { replay: "safe" }, + ); + addTool(setup.registry, work); + setup.faux.setResponses([call("work"), DONE]); + const env = ({ cwd = "/" }: { readonly cwd?: string }) => new NodeExecutionEnv({ cwd }); + const reopen = async () => { + const opened = await openChat(await openNodeSqliteStorage(path), setup, { env }); + opened.harness.resume(); + return opened; + }; + let opened = await reopen(); + await opened.root.configure({ cwd: "/one" }, context); + const id = (await opened.root.submit({ type: "input", content: "go" }, context)).id; + await started.promise; + await opened.root.configure(change === "cwd" ? { cwd: "/two" } : { extensions: [] }, context); + await opened.harness.close(context); + + opened = await reopen(); + expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done"); + const [result] = results(await allEntries(opened.root)); + if (change === "cwd") { + expect(cwds).toEqual(["/one", "/two"]); + expect(result!.isError).toBe(false); + } else { + // A tool that no longer resolves is treated as unsafe: interrupted, not rerun. + expect(cwds).toEqual(["/one"]); + expect(text(result)).toContain("was interrupted"); + } + await opened.harness.close(context); + } + }); + it("reruns beforeTool when interrupted before intent, and executes once", async () => { const path = await sqlitePath(); const setup = chatSetup(); let runs = 0; - setup.registry.tools.add( + addTool( + setup.registry, tool("work", async () => { runs++; return { content: [] }; @@ -174,7 +224,7 @@ describe("tool recovery", () => { const reached = deferred(); let asked = 0; const decisions: string[] = []; - setup.registry.hooks.add(ToolTask, { + addHooks(setup.registry, ToolTask, { beforeTool: async (_call, api, callContext) => { asked++; // A durable first-writer-wins decision survives the rerun. @@ -202,10 +252,13 @@ describe("tool recovery", () => { it("reruns the generation tools phase interrupted before its commit", async () => { const path = await sqlitePath(); const setup = chatSetup(); - setup.registry.tools.add(tool("work", async () => ({ content: [] }))); + addTool( + setup.registry, + tool("work", async () => ({ content: [] })), + ); const reached = deferred(); let observed = 0; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { afterTools: async (_assistant, _results, _api, callContext) => { observed++; if (observed === 1) { @@ -237,7 +290,7 @@ describe("tool recovery", () => { const path = await sqlitePath(); const setup = chatSetup(); const { registration, started } = blockingTool("work"); - setup.registry.tools.add(registration); + addTool(setup.registry, registration); setup.faux.setResponses([call("work"), DONE]); const opened = await open(path, setup); const submission = await opened.root.submit({ type: "input", content: "go" }, context); @@ -258,7 +311,8 @@ describe("tool recovery", () => { const path = await sqlitePath(); const setup = chatSetup(); // A result that is not strict JSON makes the result commit throw, so the scheduler faults the task. - setup.registry.tools.add( + addTool( + setup.registry, tool("bad", async () => ({ content: [], details: { fn: (() => 1) as unknown as JsonValue } })), ); const requests: string[] = []; @@ -285,7 +339,7 @@ describe("tool recovery", () => { const path = await sqlitePath(); const env = new NodeExecutionEnv({ cwd: dirname(path) }); const setup = chatSetup(); - setup.registry.tools.add(createBashTool()); + addTool(setup.registry, createBashTool()); setup.faux.setResponses([ fauxAssistantMessage([fauxToolCall("bash", { command: "echo started; sleep 30" }, { id: "b" })], { stopReason: "toolUse", @@ -314,7 +368,8 @@ describe("tool recovery", () => { const setup = chatSetup(); const started = [deferred(), deferred()]; let runs = 0; - setup.registry.tools.add( + addTool( + setup.registry, tool( "work", async (_args, api, callContext) => { diff --git a/packages/durable/test/harness-tools.test.ts b/packages/durable/test/harness-tools.test.ts index 254d08bbf..d5b021e8f 100644 --- a/packages/durable/test/harness-tools.test.ts +++ b/packages/durable/test/harness-tools.test.ts @@ -13,15 +13,20 @@ import { Type, } from "@earendil-works/pi-ai"; import { + AgentDoc, type Conversation, + configure, + defineExtension, defineTask, + defineTool, type EntryRecord, GenerationTask, type Harness, + hook, LiveDoc, MemoryStorage, - type Registration, type SubmissionId, + section, type ToolRegistration, ToolResultEntry, ToolTask, @@ -30,19 +35,16 @@ import { describe, expect, it } from "vitest"; import { NodeExecutionEnv } from "../src/env/node.ts"; import { createBashTool, createEditTool, createReadTool } from "../src/tools/index.ts"; import { allEntries, type ChatSetup, chatSetup, openChat, textOf } from "./chat-support.ts"; +import { addHooks, addTask, addTool, type Installed } from "./harness-support.ts"; import { context } from "./session-support.ts"; import { aborted, deferred } from "./task-support.ts"; -type Execute = ToolRegistration["execute"]; +const EchoParameters = Type.Object({ text: Type.Optional(Type.String()) }); +type Echo = ToolRegistration; +type Execute = Echo["execute"]; -function tool(name: string, execute: Execute, extra: Partial = {}): ToolRegistration { - return { - name, - description: `The ${name} tool`, - parameters: Type.Object({ text: Type.Optional(Type.String()) }), - execute, - ...extra, - }; +function tool(name: string, execute: Execute, extra: Partial = {}): Echo { + return defineTool({ name, description: `The ${name} tool`, parameters: EchoParameters, execute, ...extra }); } /** A tool-calling answer with one call per `[name, args, id]`. */ @@ -59,9 +61,10 @@ async function run( setup: ChatSetup, responses: FauxResponseStep[], prepare?: (harness: Harness, root: Conversation) => Promise, + options: Parameters[2] = {}, ): Promise<{ harness: Harness; root: Conversation; entries: EntryRecord[]; status: string }> { setup.faux.setResponses(responses); - const { harness, root } = await openChat(new MemoryStorage(), setup); + const { harness, root } = await openChat(new MemoryStorage(), setup, options); await prepare?.(harness, root); const settled = await (await root.submit({ type: "input", content: "go" }, context)).wait(context); return { harness, root, entries: await allEntries(root), status: settled.status }; @@ -78,9 +81,10 @@ function resultText(message: ToolResultMessage): string { describe("tool round", () => { it("runs input, tool call, tool result, and answer, and settles the input", async () => { const setup = chatSetup(); - setup.registry.tools.add( + addTool( + setup.registry, tool("echo", async (args) => ({ - content: [{ type: "text", text: `echo ${(args as { text: string }).text}` }], + content: [{ type: "text", text: `echo ${args.text}` }], })), ); const { harness, root, entries, status } = await run(setup, [calls(["echo", { text: "hi" }, "c1"]), DONE]); @@ -109,7 +113,10 @@ describe("tool round", () => { it("answers calls to tools the request did not offer without a task", async () => { const setup = chatSetup(); - setup.registry.tools.add(tool("echo", async () => ({ content: [] }))); + addTool( + setup.registry, + tool("echo", async () => ({ content: [] })), + ); const { harness, root, entries, status } = await run(setup, [ calls(["ghost", {}, "c1"], ["echo", {}, "c2"]), DONE, @@ -128,10 +135,11 @@ describe("tool round", () => { await harness.close(context); }); - it("still executes a tool that was deactivated after preparation", async () => { + it("answers a call to a tool deactivated after preparation with tool_unavailable", async () => { const setup = chatSetup(); const seen: string[] = []; - setup.registry.tools.add( + addTool( + setup.registry, tool("echo", async () => { seen.push("ran"); return { content: [] }; @@ -139,14 +147,14 @@ describe("tool round", () => { ); let root!: Conversation; const deactivate: FauxResponseStep = async () => { - await root.setActiveTools([], context); + await root.configure({ tools: [] }, context); return calls(["echo", {}, "c1"]); }; const result = await run(setup, [deactivate, DONE], async (_harness, conversation) => { root = conversation; }); - expect(seen).toEqual(["ran"]); - expect(results(result.entries)[0]!.isError).toBe(false); + expect(seen).toEqual([]); + expect(resultText(results(result.entries)[0]!)).toBe("\n[error] Tool echo is not available\n"); // The next preparation removes it. const systems = result.entries.filter((entry) => entry.kind === "pi.system"); expect((systems.at(-1)!.model![0] as SystemMessage).toolsRemoved).toEqual([{ name: "echo" }]); @@ -156,7 +164,7 @@ describe("tool round", () => { it("removes unregistered active tools from the offer and adds them back after re-registration", async () => { const setup = chatSetup(); const echo = tool("echo", async () => ({ content: [] })); - const registration = setup.registry.tools.add(echo); + const registration = addTool(setup.registry, echo); const first = await run(setup, [DONE]); registration.dispose(); setup.faux.setResponses([calls(["echo", {}, "c1"]), DONE]); @@ -166,9 +174,10 @@ describe("tool round", () => { const removal = entries.filter((entry) => entry.kind === "pi.system").at(-1)!.model![0] as SystemMessage; expect(removal.toolsRemoved).toEqual([{ name: "echo" }]); expect(results(entries)[0]).toMatchObject({ isError: true }); - expect(await first.root.getActiveTools(context)).toEqual(["echo"]); + // The stored agent is not rewritten; the tool is only not resolved. + expect((await first.root.agent(context)).tools).toEqual([]); - setup.registry.tools.add(echo); + addTool(setup.registry, echo); setup.faux.setResponses([DONE]); await (await first.root.submit({ type: "input", content: "back" }, context)).wait(context); entries = await allEntries(first.root); @@ -179,8 +188,9 @@ describe("tool round", () => { it("produces tool_unavailable when the implementation is unregistered before its task runs", async () => { const setup = chatSetup(); - let second!: Registration; - setup.registry.tools.add( + let second!: Installed; + addTool( + setup.registry, tool( "first", async () => { @@ -190,7 +200,10 @@ describe("tool round", () => { { executionMode: "sequential" }, ), ); - second = setup.registry.tools.add(tool("second", async () => ({ content: [{ type: "text", text: "ran" }] }))); + second = addTool( + setup.registry, + tool("second", async () => ({ content: [{ type: "text", text: "ran" }] })), + ); const { harness, entries, status } = await run(setup, [calls(["first", {}, "c1"], ["second", {}, "c2"]), DONE]); expect(status).toBe("done"); const [, late] = results(entries); @@ -198,6 +211,31 @@ describe("tool round", () => { await harness.close(context); }); + it("reads the execution mode when a round starts and keeps it for the round", async () => { + const setup = chatSetup(); + const events: string[] = []; + const slow = + (name: string): Execute => + async () => { + events.push(`start ${name}`); + // Changed after the round started: not seen by this round. + setup.settings.toolExecution = "parallel"; + await new Promise((resolve) => setTimeout(resolve, 20)); + events.push(`end ${name}`); + return { content: [] }; + }; + addTool(setup.registry, tool("a", slow("a"))); + addTool(setup.registry, tool("b", slow("b"))); + // Changed while the model request runs: the round that follows uses it. + const request: FauxResponseStep = () => { + setup.settings.toolExecution = "sequential"; + return calls(["a", {}, "c1"], ["b", {}, "c2"]); + }; + const { harness } = await run(setup, [request, DONE]); + expect(events).toEqual(["start a", "end a", "start b", "end b"]); + await harness.close(context); + }); + it("runs a round in parallel by default and sequentially when configured or required by a tool", async () => { const trace = async (setup: ChatSetup, prepare?: (root: Conversation) => Promise): Promise => { const events: string[] = []; @@ -209,8 +247,8 @@ describe("tool round", () => { events.push(`end ${name}`); return { content: [] }; }; - if (setup.registry.snapshot().tool("a") === undefined) setup.registry.tools.add(tool("a", slow("a"))); - if (setup.registry.snapshot().tool("b") === undefined) setup.registry.tools.add(tool("b", slow("b"))); + if (setup.registry.snapshot().extension("tool:a") === undefined) addTool(setup.registry, tool("a", slow("a"))); + if (setup.registry.snapshot().extension("tool:b") === undefined) addTool(setup.registry, tool("b", slow("b"))); const result = await run(setup, [calls(["a", {}, "c1"], ["b", {}, "c2"]), DONE], (_harness, root) => prepare === undefined ? Promise.resolve() : prepare(root), ); @@ -218,11 +256,14 @@ describe("tool round", () => { return events; }; expect((await trace(chatSetup())).slice(0, 2)).toEqual(["start a", "start b"]); - const sequential = await trace(chatSetup(), (root) => root.setToolExecution("sequential", context)); + const configured = chatSetup(); + configured.settings.toolExecution = "sequential"; + const sequential = await trace(configured); expect(sequential).toEqual(["start a", "end a", "start b", "end b"]); const perTool = chatSetup(); const events: string[] = []; - perTool.registry.tools.add( + addTool( + perTool.registry, tool( "a", async () => { @@ -233,7 +274,8 @@ describe("tool round", () => { { executionMode: "sequential" }, ), ); - perTool.registry.tools.add( + addTool( + perTool.registry, tool("b", async () => { events.push("b"); return { content: [] }; @@ -253,7 +295,8 @@ describe("tool round", () => { describe("tool results", () => { it("uses retained output and the last details when the result omits them, with diagnostics in order", async () => { const setup = chatSetup(); - setup.registry.tools.add( + addTool( + setup.registry, tool( "log", async (_args, api) => { @@ -285,7 +328,8 @@ describe("tool results", () => { it("bounds explicit text content and keeps other content", async () => { const setup = chatSetup(); const image = { type: "image", data: "AAAA", mimeType: "image/png" } as const; - setup.registry.tools.add( + addTool( + setup.registry, tool( "big", async () => ({ @@ -303,7 +347,8 @@ describe("tool results", () => { it("turns a throw into a tool_error result with the partial output", async () => { const setup = chatSetup(); - setup.registry.tools.add( + addTool( + setup.registry, tool("fail", async (_args, api) => { api.output("partial\n"); throw new Error("boom"); @@ -319,14 +364,15 @@ describe("tool results", () => { it("validates arguments before and after beforeTool and applies blocks and replacements", async () => { const setup = chatSetup(); - const seen: JsonValue[] = []; - setup.registry.tools.add( + const seen: { text?: string }[] = []; + addTool( + setup.registry, tool("echo", async (args) => { seen.push(args); return { content: [] }; }), ); - setup.registry.hooks.add(ToolTask, { + addHooks(setup.registry, ToolTask, { beforeTool: (call) => { if (call.id === "block") return { block: "not today" }; if (call.id === "throw") throw new Error("hook failed"); @@ -363,8 +409,9 @@ describe("tool results", () => { it("repairs arguments with prepareArguments before validation, and a throwing repair is invalid", async () => { const setup = chatSetup(); - const seen: JsonValue[] = []; - setup.registry.tools.add( + const seen: { text?: string }[] = []; + addTool( + setup.registry, tool( "echo", async (args) => { @@ -375,7 +422,7 @@ describe("tool results", () => { prepareArguments: (args) => { const text = (args as { text?: unknown }).text; if (text === "throw") throw new Error("cannot repair"); - return typeof text === "number" ? { text: `#${text}` } : args; + return typeof text === "number" ? { text: `#${text}` } : (args as { text?: string }); }, }, ), @@ -396,15 +443,18 @@ describe("tool results", () => { it("lets the first beforeTool block win and skips later handlers", async () => { const setup = chatSetup(); - setup.registry.tools.add(tool("echo", async () => ({ content: [] }))); + addTool( + setup.registry, + tool("echo", async () => ({ content: [] })), + ); const asked: string[] = []; - setup.registry.hooks.add(ToolTask, { + addHooks(setup.registry, ToolTask, { beforeTool: () => { asked.push("first"); return { block: "first says no" }; }, }); - setup.registry.hooks.add(ToolTask, { + addHooks(setup.registry, ToolTask, { beforeTool: () => { asked.push("second"); return { block: "second says no" }; @@ -418,15 +468,18 @@ describe("tool results", () => { it("chains afterTool replacements and observes the round with afterTools", async () => { const setup = chatSetup(); - setup.registry.tools.add(tool("echo", async () => ({ content: [{ type: "text", text: "raw" }] }))); - setup.registry.hooks.add(ToolTask, { + addTool( + setup.registry, + tool("echo", async () => ({ content: [{ type: "text", text: "raw" }] })), + ); + addHooks(setup.registry, ToolTask, { afterTool: (_call, result) => ({ ...result, content: [{ type: "text", text: "first" }] }), }); - setup.registry.hooks.add(ToolTask, { + addHooks(setup.registry, ToolTask, { afterTool: (_call, result) => ({ ...result, details: { replaced: resultText(result as ToolResultMessage) } }), }); const observed: unknown[] = []; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { afterTools: (assistant, entries) => void observed.push(assistant, entries), }); const { harness, entries } = await run(setup, [calls(["echo", {}, "c1"]), DONE]); @@ -438,11 +491,20 @@ describe("tool results", () => { await harness.close(context); }); - it("matches hook scopes to the conversation and its owned subtree", async () => { + it("runs the hooks of the selected extensions; a task-owned child copies its owner's selection", async () => { const setup = chatSetup(); - setup.registry.tools.add(tool("echo", async () => ({ content: [] }))); const calledIn: number[] = []; + const Echo = defineExtension({ name: "echo", tools: [tool("echo", async () => ({ content: [] }))] }); + const Audit = defineExtension({ + name: "audit", + hooks: [hook(ToolTask, { beforeTool: (_call, api) => void calledIn.push(api.conversationId) })], + }); + setup.registry.install(Echo); + setup.registry.install(Audit); + // Audit is installed but not in the default selection. + setup.settings.extensions = [Echo]; const first = await run(setup, [DONE]); + await first.root.configure({ extensions: { add: [Audit] } }, context); // An owner task no registered definition takes stays live and pending. const owner = defineTask, { phase: "never" }, null>({ name: "test.owner", @@ -460,35 +522,30 @@ describe("tool results", () => { return (await tx.createConversation({ ownership: { kind: "task", taskId } })).id; }, context); const child = (await first.harness.conversation(childId, context))!; - const other = await first.harness.createConversation({ ownership: { kind: "ownerless" } }, context); - for (const conversation of [child, other]) { - await conversation.setModel({ provider: "faux", modelId: "faux-1" }, context); - } - setup.registry.hooks.add( - ToolTask, - { beforeTool: (_call, api) => void calledIn.push(api.conversationId) }, - { scope: { conversationId: first.root.id, subtree: true } }, - ); - setup.registry.hooks.add( - ToolTask, - { beforeTool: (_call, api) => void calledIn.push(-api.conversationId) }, - { scope: { conversationId: first.root.id } }, + const other = await first.harness.createConversation( + { ownership: { kind: "ownerless" }, agent: { model: { provider: "faux", modelId: "faux-1" } } }, + context, ); for (const conversation of [first.root, child, other]) { setup.faux.setResponses([calls(["echo", {}, "c1"]), DONE]); await (await conversation.submit({ type: "input", content: "go" }, context)).wait(context); } - expect(calledIn).toEqual([first.root.id, -first.root.id, child.id]); + expect(calledIn).toEqual([first.root.id, child.id]); await first.harness.close(context); }); it("applies addTools and terminates only when every result of the round asks to", async () => { const setup = chatSetup(); - setup.registry.tools.add(tool("stop", async () => ({ content: [], control: { terminate: true } }))); - setup.registry.tools.add(tool("grow", async () => ({ content: [], control: { addTools: ["extra", "stop"] } }))); - setup.registry.tools.add(tool("extra", async () => ({ content: [] }))); + const stop = tool("stop", async () => ({ content: [], control: { terminate: true } })); + const grow = tool("grow", async () => ({ content: [], control: { addTools: ["extra", "stop"] } })); + addTool(setup.registry, stop); + addTool(setup.registry, grow); + addTool( + setup.registry, + tool("extra", async () => ({ content: [] })), + ); const first = await run(setup, [calls(["stop", {}, "c1"])], async (_harness, root) => { - await root.setActiveTools(["stop", "grow"], context); + await root.configure({ tools: [stop, grow] }, context); }); expect(first.status).toBe("done"); expect(first.entries.at(-1)!.kind).toBe("pi.tool-result"); @@ -499,7 +556,12 @@ describe("tool results", () => { const second = await (await first.root.submit({ type: "input", content: "again" }, context)).wait(context); expect(second.status).toBe("done"); expect((await allEntries(first.root)).at(-1)!.kind).toBe("pi.assistant"); - expect(await first.root.getActiveTools(context)).toEqual(["stop", "grow", "extra"]); + // addTools appends to the stored tool array, skipping names it already holds. + expect((await first.harness.snapshot(AgentDoc, first.root.id, context))?.tools).toEqual([ + "stop", + "grow", + "extra", + ]); await first.harness.close(context); }); }); @@ -512,17 +574,17 @@ describe("generation hooks", () => { requests.push(request.messages.map((message) => `${message.role}:${textOf(message as never) ?? ""}`)); return fauxAssistantMessage([fauxText(`answer ${requests.length}`)]); }; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { beforeRequest: ({ messages }) => ({ messages: [...messages, { role: "user", content: "injected", timestamp: 0 }], }), }); const responses: string[] = []; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { afterResponse: (message) => void responses.push(textOf(message) ?? ""), }); let yields = 0; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { onYield: () => (yields++ === 0 ? { continue: [{ type: "text", text: "keep going" }] } : undefined), }); const { harness, entries, status } = await run(setup, [record, record]); @@ -541,7 +603,7 @@ describe("generation hooks", () => { let harness!: Harness; let input: SubmissionId | undefined; let statusAtSecondRequest: string | undefined; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "again" } : undefined), }); const second: FauxResponseStep = async () => { @@ -564,12 +626,11 @@ describe("generation hooks", () => { it("observes responses that arrive by polling a deferred request", async () => { const setup = chatSetup({ deferred: { pendingFetches: 1, pollAfterMs: 1 } }); const observed: string[] = []; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { afterResponse: (message) => void observed.push(`${message.stopReason}:${textOf(message) ?? ""}`), }); - const result = await run(setup, [fauxAssistantMessage([fauxText("late")])], (_harness, conversation) => - conversation.setStreamOptions({ deferred: true }, context), - ); + setup.settings.stream = { deferred: true }; + const result = await run(setup, [fauxAssistantMessage([fauxText("late")])]); expect(result.status).toBe("done"); // The still-deferred results are not terminal. expect(observed).toEqual(["stop:late"]); @@ -579,18 +640,18 @@ describe("generation hooks", () => { it("lets the first onYield continuation win and reports throws without stopping later handlers", async () => { const setup = chatSetup(); const called: string[] = []; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { afterResponse: () => { called.push("throwing observer"); throw new Error("observer failed"); }, }); - setup.registry.hooks.add(GenerationTask, { afterResponse: () => void called.push("next observer") }); + addHooks(setup.registry, GenerationTask, { afterResponse: () => void called.push("next observer") }); let yields = 0; - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "first" } : undefined), }); - setup.registry.hooks.add(GenerationTask, { + addHooks(setup.registry, GenerationTask, { onYield: () => { called.push("second onYield"); return called.filter((name) => name === "second onYield").length === 1 ? { continue: "second" } : undefined; @@ -613,8 +674,11 @@ describe("generation hooks", () => { it("keeps durable hook decisions in task memos", async () => { const setup = chatSetup(); let asked = 0; - setup.registry.tools.add(tool("echo", async () => ({ content: [] }))); - setup.registry.hooks.add(ToolTask, { + addTool( + setup.registry, + tool("echo", async () => ({ content: [] })), + ); + addHooks(setup.registry, ToolTask, { beforeTool: async (_call, api) => { asked++; const decision = await api.memo("approval:decision", "approved", context); @@ -629,10 +693,8 @@ describe("generation hooks", () => { }); describe("tool execution api", () => { - it("provides the Harness environment, lets wrappers replace it, and runs commits, memos, and child tasks", async () => { + it("builds the environment per call from the conversation's cwd and runs commits, memos, and child tasks", async () => { const setup = chatSetup(); - const env = new NodeExecutionEnv({ cwd: "/tmp" }); - const other = new NodeExecutionEnv({ cwd: "/" }); const child = defineTask<{ n: number }, { phase: "run" }, number>({ name: "test.child", version: 1, @@ -647,36 +709,60 @@ describe("tool execution api", () => { }, abort: async () => {}, }); - setup.registry.tasks.add(child); + addTask(setup.registry, child); const seen: unknown[] = []; - setup.registry.tools.add( - tool("probe", async (_args, api) => { - seen.push(api.env?.cwd); - const entry = await api.commit( - (tx) => tx.appendEntry(api.conversationId, { kind: "test.note", data: api.callId }), - context, - ); - seen.push(entry.byTaskId === api.taskId); - seen.push(await api.memo("m", 1, context), await api.memo("m", 2, context)); - const id = await api.createTask(child, { n: 21 }, { ownership: { kind: "conversation" } }, context); - const done = await api.waitForTask(id, context); - seen.push(done.state.outcome); - return { content: [] }; - }), - ); - setup.registry.tools.wrap("probe", "cwd", (base) => ({ - ...base, - execute: (args, api, callContext) => - api.callId === "c2" - ? base.execute(args, { ...api, env: other }, callContext) - : base.execute(args, api, callContext), - })); + const probe = tool("probe", async (_args, api) => { + seen.push(api.env?.cwd); + seen.push((await api.agent(context)).tools.map((each) => each.name)); + seen.push(api.registry.extension("tool:probe") !== undefined); + const entry = await api.commit(async (tx) => { + // The next call runs in the new directory: its environment is built when it executes. + await configure(tx, api.conversationId, { cwd: "/" }); + return tx.appendEntry(api.conversationId, { kind: "test.note", data: api.callId }); + }, context); + seen.push(entry.byTaskId === api.taskId); + seen.push(await api.memo("m", 1, context), await api.memo("m", 2, context)); + const id = await api.createTask(child, { n: 21 }, { ownership: { kind: "conversation" } }, context); + const done = await api.waitForTask(id, context); + seen.push(done.state.outcome); + return { content: [] }; + }); + addTool(setup.registry, probe); setup.faux.setResponses([calls(["probe", {}, "c1"], ["probe", {}, "c2"]), DONE]); - const { harness, root } = await openChat(new MemoryStorage(), setup, { env }); - await root.setToolExecution("sequential", context); + setup.settings.toolExecution = "sequential"; + const targets: unknown[] = []; + const { harness, root } = await openChat(new MemoryStorage(), setup, { + env: ({ conversationId, cwd }) => { + targets.push([conversationId, cwd]); + return new NodeExecutionEnv({ cwd: cwd ?? "/tmp" }); + }, + }); + await root.configure({ cwd: "/tmp" }, context); await (await root.submit({ type: "input", content: "go" }, context)).wait(context); const outcome = { status: "completed", result: 42 }; - expect(seen).toEqual(["/tmp", true, 1, 1, outcome, "/", true, 1, 1, outcome]); + const call = (cwd: string) => [cwd, ["probe"], true, true, 1, 1, outcome]; + expect(seen).toEqual([...call("/tmp"), ...call("/")]); + expect(targets).toContainEqual([root.id, "/tmp"]); + expect(targets).toContainEqual([root.id, "/"]); + await harness.close(context); + }); + + it("answers a throwing environment with a tool_error result and reports it once while preparing", async () => { + const setup = chatSetup(); + addTool( + setup.registry, + tool("probe", async () => ({ content: [{ type: "text", text: "ran" }] })), + ); + const { harness, entries } = await run(setup, [calls(["probe", {}, "c1"]), DONE], undefined, { + env: () => { + throw new Error("no sandbox"); + }, + }); + const [result] = results(entries); + expect(result).toMatchObject({ isError: true }); + expect(resultText(result!)).toBe("\n[error] no sandbox\n"); + // Each preparation reports the failure and renders without an environment. + expect(setup.reports.filter((error) => (error as Error).message === "no sandbox").length).toBe(2); await harness.close(context); }); }); @@ -686,7 +772,7 @@ describe("coding tools", () => { const directory = mkdtempSync(join(tmpdir(), "pi-durable-coding-")); try { const setup = chatSetup(); - setup.registry.tools.add(createBashTool()); + addTool(setup.registry, createBashTool()); const command = "i=1; while [ $i -le 3000 ]; do echo line-$i; i=$((i + 1)); done; exit 7"; setup.faux.setResponses([calls(["bash", { command }, "b"]), DONE]); const env = new NodeExecutionEnv({ cwd: directory }); @@ -717,11 +803,9 @@ describe("coding tools", () => { try { writeFileSync(join(directory, "notes.txt"), "hello world\n"); const setup = chatSetup(); - setup.registry.batch(() => { - setup.registry.tools.add(createReadTool()); - setup.registry.tools.add(createEditTool()); - setup.registry.tools.add(createBashTool()); - }); + setup.registry.install( + defineExtension({ name: "coding", tools: [createReadTool(), createEditTool(), createBashTool()] }), + ); setup.faux.setResponses([ calls(["read", { path: "notes.txt" }, "r"]), calls(["edit", { path: "notes.txt", edits: [{ oldText: "world", newText: "durable" }] }, "e"]), @@ -750,7 +834,8 @@ describe("coding tools", () => { describe("tool progress and lifetime", () => { it("applies the default output limits", async () => { const setup = chatSetup(); - setup.registry.tools.add( + addTool( + setup.registry, tool("lines", async (_args, api) => { for (let index = 1; index <= 2500; index++) api.output(`${index}\n`); return {}; @@ -770,7 +855,8 @@ describe("tool progress and lifetime", () => { it("sanitizes running output but keeps explicit result content as the tool returned it", async () => { const setup = chatSetup(); let slotOutput: string | undefined; - setup.registry.tools.add( + addTool( + setup.registry, tool("noisy", async (_args, api) => { api.output("a\u0007b\r\n"); await api.details({ ready: true }, context); @@ -778,7 +864,10 @@ describe("tool progress and lifetime", () => { return {}; }), ); - setup.registry.tools.add(tool("explicit", async () => ({ content: [{ type: "text", text: "c\u001bd" }] }))); + addTool( + setup.registry, + tool("explicit", async () => ({ content: [{ type: "text", text: "c\u001bd" }] })), + ); const { harness, entries } = await run(setup, [calls(["noisy", {}, "c1"], ["explicit", {}, "c2"]), DONE]); expect(slotOutput).toBe("ab\n"); const byId = new Map(results(entries).map((result) => [result.toolCallId, resultText(result)])); @@ -789,21 +878,25 @@ describe("tool progress and lifetime", () => { it("drops control keys set to undefined instead of faulting", async () => { const setup = chatSetup(); - setup.registry.tools.add( + addTool( + setup.registry, tool("grow", async () => ({ content: [], control: { addTools: ["extra"], terminate: undefined } })), ); - setup.registry.tools.add(tool("extra", async () => ({ content: [] }))); + const extra = tool("extra", async () => ({ content: [] })); + addTool(setup.registry, extra); const { harness, root, status } = await run(setup, [calls(["grow", {}, "c1"]), DONE], (_harness, conversation) => - conversation.setActiveTools(["grow"], context), + conversation.configure({ tools: { remove: [extra] } }, context), ); expect(status).toBe("done"); - expect(await root.getActiveTools(context)).toEqual(["grow", "extra"]); + // addTools deletes the name from a stored `{ remove }` filter. + expect((await harness.snapshot(AgentDoc, root.id, context))?.tools).toEqual({ remove: [] }); await harness.close(context); }); it("uses explicit null details instead of the last reported value", async () => { const setup = chatSetup(); - setup.registry.tools.add( + addTool( + setup.registry, tool("null", async (_args, api) => { await api.details({ old: 1 }, context); return { content: [], details: null }; @@ -817,7 +910,8 @@ describe("tool progress and lifetime", () => { it("settles details() promises with coalesced progress commits and the terminal commit", async () => { const setup = chatSetup(); const settled: string[] = []; - setup.registry.tools.add( + addTool( + setup.registry, tool("details", async (_args, api) => { // Three updates in one throttle window coalesce; the last is still pending when execute() returns. const first = api.details({ n: 1 }, context).then(() => settled.push("first")); @@ -849,22 +943,58 @@ describe("tool progress and lifetime", () => { await finished; return { content: [{ type: "text", text: "v1" }] }; }); - let registration = setup.registry.tools.add(v1); + addTool(setup.registry, v1); setup.faux.setResponses([calls(["work", {}, "c1"]), DONE]); const { harness, root } = await openChat(new MemoryStorage(), setup); const submission = await root.submit({ type: "input", content: "go" }, context); await started; - const previous = registration; - registration = setup.registry.batch(() => { - previous.dispose(); - setup.registry.tools.add(tool("work", async () => ({ content: [{ type: "text", text: "v2" }] }))); - }); + // The same extension name replaces the old one in place. + addTool( + setup.registry, + tool("work", async () => ({ content: [{ type: "text", text: "v2" }] })), + ); finish(); await submission.wait(context); expect(resultText(results(await allEntries(root))[0]!)).toBe("v1"); await harness.close(context); }); + it("uses a section and hook extension reloaded mid-run from the run's next request", async () => { + const setup = chatSetup(); + const requests: string[] = []; + const prompt = (version: string) => + defineExtension({ + name: "prompt", + sections: [section("mode", () => version)], + hooks: [hook(GenerationTask, { beforeRequest: () => void requests.push(version) })], + }); + setup.registry.install(prompt("v1")); + const running = deferred(); + const reloaded = deferred(); + addTool( + setup.registry, + tool("work", async () => { + running.resolve(); + await reloaded.promise; + return { content: [] }; + }), + ); + setup.faux.setResponses([calls(["work", {}, "c1"]), DONE]); + const { harness, root } = await openChat(new MemoryStorage(), setup); + const submission = await root.submit({ type: "input", content: "go" }, context); + await running.promise; + setup.registry.install(prompt("v2")); + reloaded.resolve(); + await submission.wait(context); + const sections = (await allEntries(root)).flatMap((entry) => { + const message = entry.model?.[0]; + return message?.role === "system" && message.sections !== undefined ? [message.sections] : []; + }); + expect(sections).toEqual([{ mode: "\nv1\n" }, { mode: "\nv2\n" }]); + expect(requests).toEqual(["v1", "v2"]); + await harness.close(context); + }); + it("rejects invocation-bound waits and stops watches when the tool's invocation ends", async () => { const setup = chatSetup(); const never = defineTask, { phase: "never" }, null>({ @@ -876,7 +1006,8 @@ describe("tool progress and lifetime", () => { }); let wait!: Promise; let watchClosed!: Promise; - setup.registry.tools.add( + addTool( + setup.registry, tool("detach", async (_args, api) => { // The child's definition is not registered, so it stays pending. const child = await api.createTask(never, {}, { ownership: { kind: "conversation" } }, context); @@ -897,7 +1028,8 @@ describe("tool progress and lifetime", () => { const setup = chatSetup(); let pendingDetails!: Promise; const inAfterTool = deferred(); - setup.registry.tools.add( + addTool( + setup.registry, tool("slow", async (_args, api) => { api.output("first\n"); // The output commit is in flight, so these details wait for the next throttle window. @@ -906,7 +1038,7 @@ describe("tool progress and lifetime", () => { return {}; }), ); - setup.registry.hooks.add(ToolTask, { + addHooks(setup.registry, ToolTask, { afterTool: async (_call, _result, _api, callContext) => { inAfterTool.resolve(); await aborted(callContext.abortSignal!); @@ -930,7 +1062,8 @@ describe("tool progress and lifetime", () => { const buffered = new Promise((resolve) => { reached = resolve; }); - setup.registry.tools.add( + addTool( + setup.registry, tool("slow", async (_args, api, callContext) => { api.output("durable\n"); // The first output commits at once; this one waits for the next throttle window. diff --git a/packages/durable/test/harness-view.test.ts b/packages/durable/test/harness-view.test.ts index 2e6719d39..4d5a0e521 100644 --- a/packages/durable/test/harness-view.test.ts +++ b/packages/durable/test/harness-view.test.ts @@ -1,8 +1,8 @@ import { applyImmutable, type Op } from "@earendil-works/chord/delta"; import { type AssistantMessage, type FauxResponseStep, fauxAssistantMessage, fauxText } from "@earendil-works/pi-ai"; import { + AgentDoc, type Conversation, - ConversationConfig, type ConversationView, defineDoc, type Harness, @@ -18,7 +18,7 @@ import { aborted, deferred } from "./task-support.ts"; type Frame = { value: ConversationView; ops: readonly Op[] }; -const MOUNTED = ["pi.conversation.config", "pi.inbox", "pi.live", "pi.usage"]; +const MOUNTED = ["pi.agent", "pi.inbox", "pi.live", "pi.usage"]; /** Start a watch of `conversation` that records its acquisition revision and every delivered frame. */ async function record(conversation: Conversation) { @@ -42,7 +42,7 @@ async function fresh(conversation: Conversation): Promise { /** The view as committed state defines it, read without any mount: the active entries and the built-in documents. */ async function committed(harness: Harness, conversation: Conversation, record: ConversationView["conversation"]) { const docs: Record = {}; - for (const token of [ConversationConfig, LiveDoc, InboxDoc, UsageDoc] as const) { + for (const token of [AgentDoc, LiveDoc, InboxDoc, UsageDoc] as const) { const value = await harness.snapshot(token as typeof LiveDoc, conversation.id, context); if (value !== undefined) docs[token.definition.kind] = value; } @@ -135,11 +135,11 @@ describe("conversation view", () => { const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context); const { initial, frames, stop } = await record(root); await other.commit((tx) => tx.appendEntry(other.id, { kind: "note" }), context); - await root.setThinkingLevel("high", context); + await root.configure({ thinkingLevel: "high" }, context); await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context); await drained(); expect(frames).toHaveLength(2); - expect(frames[0]!.ops).toEqual([["s", ["docs", "pi.conversation.config", "thinkingLevel"], "high"]]); + expect(frames[0]!.ops).toEqual([["s", ["docs", "pi.agent", "thinkingLevel"], "high"]]); expect(frames[0]!.value.entries).toBe(initial.entries); expect(frames[0]!.value.docs["pi.live"]).toBe(initial.docs["pi.live"]); expect(frames[1]!.value.docs).toBe(frames[0]!.value.docs); diff --git a/packages/durable/test/task-support.ts b/packages/durable/test/task-support.ts index daea8d65a..403fc40d7 100644 --- a/packages/durable/test/task-support.ts +++ b/packages/durable/test/task-support.ts @@ -2,7 +2,9 @@ import { createModels } from "@earendil-works/pi-ai"; import { type AnyTask, createRegistry, + defineExtension, Harness, + type HarnessSettings, type Registry, type RegistryReader, type Storage, @@ -61,10 +63,10 @@ export async function settled(promise: Promise): Promise { export async function openTasks( storage: Storage, tasks: readonly AnyTask[], - options: { readonly registry?: Registry; readonly now?: () => number } = {}, + options: { readonly registry?: Registry; readonly now?: () => number; readonly settings?: HarnessSettings } = {}, ): Promise<{ readonly harness: Harness; readonly registry: Registry; readonly reports: unknown[] }> { const registry = options.registry ?? createRegistry(); - for (const task of tasks) registry.tasks.add(task); + if (tasks.length > 0) registry.install(defineExtension({ name: "tasks", tasks })); const reports: unknown[] = []; const harness = await Harness.open( storage, @@ -73,6 +75,7 @@ export async function openTasks( registry, onReport: (error) => reports.push(error), ...(options.now === undefined ? {} : { now: options.now }), + ...(options.settings === undefined ? {} : { settings: options.settings }), }, context, ); diff --git a/packages/durable/test/tool-output-bench.ts b/packages/durable/test/tool-output-bench.ts index 2cee4ff8b..040c3c7b4 100644 --- a/packages/durable/test/tool-output-bench.ts +++ b/packages/durable/test/tool-output-bench.ts @@ -18,10 +18,11 @@ import { createModels } from "@earendil-works/pi-ai/models"; import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux"; import { Type } from "typebox"; import { NodeExecutionEnv } from "../src/env/node.ts"; -import { ConversationConfig, createRegistry, Harness, LiveDoc, MemoryStorage, type Storage } from "../src/index.ts"; +import { createRegistry, defineTool, Harness, LiveDoc, MemoryStorage, type Storage } from "../src/index.ts"; import { openNodeJsonlStorage } from "../src/storage/jsonl/node.ts"; import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts"; import { createBashTool } from "../src/tools/index.ts"; +import { addTool } from "./harness-support.ts"; type Backend = "memory" | "sqlite" | "jsonl"; type Rate = "low" | "normal" | "high"; @@ -136,42 +137,47 @@ async function runScenario(scenario: Scenario): Promise { const registry = createRegistry(); const toolCount = scenario.kind === "rate" ? scenario.tools : 1; if (scenario.kind === "cat") { - registry.tools.add(createBashTool()); + addTool(registry, createBashTool()); } else { const rate = scenario.kind === "rate" ? RATES[scenario.rate] : RATES.high; - registry.tools.add({ - name: "emit", - description: "", - parameters: Type.Object({}), - outputLimits: { retain: scenario.retain }, - execute: async (_args, api, callContext) => { - let counter = 0; - let written = 0; - // The firehose reuses one prebuilt chunk so it measures the Harness, not string building. - const prebuilt = scenario.kind === "firehose" ? lines(api.callId, 0, rate.lines) : undefined; - // Repetitive: identical lines with one numbered line per chunk, so the tail window keeps sliding. - const repeated = "y\n".repeat(32 * 1024 - 8); - while (!stop && (scenario.kind === "rate" || written < GIB)) { - const chunk = - scenario.kind === "firehose" && scenario.repetitive === true - ? `${repeated}#${counter}\n` - : (prebuilt ?? lines(api.callId, counter, rate.lines)); - counter += rate.lines; - api.output(chunk); - written += chunk.length; - await new Promise((resolve) => - rate.pauseMs === 0 ? setImmediate(resolve) : setTimeout(resolve, rate.pauseMs), - ); - } - // A persistent rate scenario stays in flight until close, so reopen replays the round. - if (scenario.kind === "rate" && scenario.backend !== "memory") { - await new Promise((_, reject) => - callContext.abortSignal!.addEventListener("abort", () => reject(callContext.abortSignal!.reason)), - ); - } - return {}; - }, - }); + addTool( + registry, + defineTool({ + name: "emit", + description: "", + parameters: Type.Object({}), + outputLimits: { retain: scenario.retain }, + execute: async (_args, api, callContext) => { + let counter = 0; + let written = 0; + // The firehose reuses one prebuilt chunk so it measures the Harness, not string building. + const prebuilt = scenario.kind === "firehose" ? lines(api.callId, 0, rate.lines) : undefined; + // Repetitive: identical lines with one numbered line per chunk, so the tail window keeps sliding. + const repeated = "y\n".repeat(32 * 1024 - 8); + while (!stop && (scenario.kind === "rate" || written < GIB)) { + const chunk = + scenario.kind === "firehose" && scenario.repetitive === true + ? `${repeated}#${counter}\n` + : (prebuilt ?? lines(api.callId, counter, rate.lines)); + counter += rate.lines; + api.output(chunk); + written += chunk.length; + await new Promise((resolve) => + rate.pauseMs === 0 ? setImmediate(resolve) : setTimeout(resolve, rate.pauseMs), + ); + } + // A persistent rate scenario stays in flight until close, so reopen replays the round. + if (scenario.kind === "rate" && scenario.backend !== "memory") { + await new Promise((_, reject) => + callContext.abortSignal!.addEventListener("abort", () => + reject(callContext.abortSignal!.reason), + ), + ); + } + return {}; + }, + }), + ); } const call = scenario.kind === "cat" @@ -181,13 +187,10 @@ async function runScenario(scenario: Scenario): Promise { fauxAssistantMessage(call, { stopReason: "toolUse" }), fauxAssistantMessage([fauxText("done")]), ]); - const env = new NodeExecutionEnv({ cwd: directory }); + const nodeEnv = new NodeExecutionEnv({ cwd: directory }); + const env = () => nodeEnv; let harness = await Harness.open(await openTimed(), { models, registry, env }, context); - const root = await harness.root(context, { - init: async (tx, id) => { - (await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" }; - }, - }); + const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); let liveCommits = 0; let liveOpBytes = 0; let outputSets = 0; diff --git a/packages/durable/test/tools.test.ts b/packages/durable/test/tools.test.ts index f67ae2c3c..527774c45 100644 --- a/packages/durable/test/tools.test.ts +++ b/packages/durable/test/tools.test.ts @@ -25,6 +25,7 @@ import { type ShellExecResult, } from "../src/env/index.ts"; import { NodeExecutionEnv } from "../src/env/node.ts"; +import { withFileMutationQueue } from "../src/tools/file-mutation-queue.ts"; import { detectSupportedImageMimeType } from "../src/tools/image.ts"; import { createBashTool, createEditTool, createReadTool, createWriteTool } from "../src/tools/index.ts"; import { DEFAULT_MAX_LINES } from "../src/truncate.ts"; @@ -422,6 +423,84 @@ describe("durable tools", () => { expect(getOrThrow(await env.readTextFile("target.txt", BACKGROUND_CONTEXT))).toBe("ALPHA\nBETA\ngamma\n"); }); + it("serializes edits of one file across environment objects of one file system", async () => { + const dir = createTempDir(); + const first = new SlowReadExecutionEnv({ cwd: dir }); + const second = new SlowReadExecutionEnv({ cwd: dir }); + getOrThrow(await first.writeFile("file.txt", "alpha\nbeta\n", BACKGROUND_CONTEXT)); + const tool = createEditTool(); + await Promise.all([ + run(tool, { path: "file.txt", edits: [{ oldText: "alpha", newText: "ALPHA" }] }, first), + run(tool, { path: "file.txt", edits: [{ oldText: "beta", newText: "BETA" }] }, second), + ]); + expect(getOrThrow(await first.readTextFile("file.txt", BACKGROUND_CONTEXT))).toBe("ALPHA\nBETA\n"); + }); + + it("serializes a new file created through a symlinked directory with its canonical path", async () => { + const env = new BlockingWriteExecutionEnv({ cwd: createTempDir() }); + mkdirSync(`${env.cwd}/real`); + await symlink(`${env.cwd}/real`, `${env.cwd}/link`); + const tool = createWriteTool(); + const first = run(tool, { path: "link/new.txt", content: "first\n" }, env); + await env.firstWriteStarted.promise; + const second = run(tool, { path: "real/new.txt", content: "second\n" }, env); + await delay(20); + expect(env.secondWriteStarted).toBe(false); + env.finishFirstWrite.resolve(); + await Promise.all([first, second]); + expect(getOrThrow(await env.readTextFile("real/new.txt", BACKGROUND_CONTEXT))).toBe("second\n"); + }); + + it.skipIf(process.platform === "win32")( + "keys a missing file whose name contains a backslash like the created file", + async () => { + const env = createEnv(); + const created = deferred(); + const release = deferred(); + const first = withFileMutationQueue( + env, + "a\\b.txt", + async () => { + getOrThrow(await env.writeFile("a\\b.txt", "first\n", BACKGROUND_CONTEXT)); + created.resolve(); + await release.promise; + }, + BACKGROUND_CONTEXT, + ); + await created.promise; + let entered = false; + const second = withFileMutationQueue( + env, + "a\\b.txt", + async () => { + entered = true; + }, + BACKGROUND_CONTEXT, + ); + await delay(20); + expect(entered).toBe(false); + release.resolve(); + await Promise.all([first, second]); + expect(entered).toBe(true); + }, + ); + + it("does not serialize the same path on different file systems", async () => { + class OtherFileSystem extends BlockingWriteExecutionEnv { + override readonly id = "other"; + } + const dir = createTempDir(); + const local = new BlockingWriteExecutionEnv({ cwd: dir }); + const other = new OtherFileSystem({ cwd: dir }); + const tool = createWriteTool(); + const blocked = run(tool, { path: "file.txt", content: "first\n" }, local); + await local.firstWriteStarted.promise; + await run(tool, { path: "file.txt", content: "second\n" }, other); + expect(other.secondWriteStarted).toBe(true); + local.finishFirstWrite.resolve(); + await blocked; + }); + it("edits regular files through symlinks", async () => { const env = createEnv(); getOrThrow(await env.writeFile("target.txt", "before\n", BACKGROUND_CONTEXT)); diff --git a/scripts/check-entry-graphs.mjs b/scripts/check-entry-graphs.mjs index 6932959cf..7154c5cf5 100644 --- a/scripts/check-entry-graphs.mjs +++ b/scripts/check-entry-graphs.mjs @@ -44,7 +44,7 @@ const BUDGETS = { "packages/durable": { ".": { // The built-in tool task validates arguments with pi-ai's TypeBox-based validation, so TypeBox is allowed. - maxFiles: 58, + maxFiles: 59, forbid: ["packages/ai/src/index.ts", "packages/ai/src/utils/typebox-helpers.ts"], }, },