diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 5cbf0ea4a..c559b489e 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -20,6 +20,13 @@ - Added indexed conversation ownership queries and guaranteed no-effect Storage rejection handling. - Added incarnation-bound read-only Chord document states and serialized asynchronous document watches with bounded exact-frame buffering. - Added `deltasSinceBase` checkpoint predicate information so definitions can bound replay without value counters. +- Added `Harness.open()` with lazy root creation, atomic conversation creation and forks with `init`, conversation-bound commits, fork-aware entry pagination, model context derivation, and the built-in `ConversationConfig` document with model, thinking level, and active tool accessors. +- Added `createRegistry()` for tools, tool wrappers, hooks, tasks, and system prompt sections with batched publication and stable keyed ordering. +- Added `defineEntry()` typed entry kinds. + +### Fixed + +- Fixed cached documents skipping migration when accessed with a newer definition version, and older definitions reading values migrated only in memory. Document states and watches hydrated under another definition version receive the new value as a root replacement. ## [0.87.1] - 2026-09-22 diff --git a/packages/durable/docs/pico-v5-chord-usage.md b/packages/durable/docs/pico-v5-chord-usage.md index 57c695d84..b34c61d24 100644 --- a/packages/durable/docs/pico-v5-chord-usage.md +++ b/packages/durable/docs/pico-v5-chord-usage.md @@ -126,10 +126,10 @@ async function runCanvasExample(session: Session): Promise { The application owns the open Session. Acquire the document state asynchronously before synchronous `setup`, then transfer its disposal to the facet; `env.provide` cannot run in `onActivate`. Install one provider per -Session host. Chord may reload presentation facets independently, but v1 does -not use facet reload to replace Session-side task or hook implementations. A -host extension code change closes and reopens the Harness with the new -definition set. +Session host. Chord may reload presentation facets independently. Session-side +tools, hooks, tasks, and prompt sections reload through the Harness +registry: publish the replacement in one `batch()`. Work already running keeps +the old code, so the facet manages its old resources' lifetime itself. Remote clients supply a `RemoteServiceTransport` connected to the host's `services` provider; Chord prescribes no socket protocol. `CanvasConsumer` also works unchanged @@ -338,7 +338,7 @@ dynamic service should then withdraw its instance rather than expose stale data. ```text addStroke -> hold Session mutation line -> await tx.doc -> mutate tracker change draft -callback succeeds -> tracker prepare: immutable candidate + frozen ops +callback succeeds -> tracker prepare: immutable candidate + immutable ops Session checkpoint predicate selects a base or delta exactly once atomic storage commit: persist selected document and record writes storage succeeds -> adopt candidate + enqueue candidate/ops, still on line diff --git a/packages/durable/docs/pico-v5-handoff.md b/packages/durable/docs/pico-v5-handoff.md index 96ec49094..44351e780 100644 --- a/packages/durable/docs/pico-v5-handoff.md +++ b/packages/durable/docs/pico-v5-handoff.md @@ -11,7 +11,7 @@ facades, membranes, document routing, view projection, events, or clone chains. - Obsolete `pico` and `pico4` prototypes were removed. - `pico3` remains. -- Packages 1–12 are implemented in `packages/durable`; Package 10 was already satisfied by Chord's canonical structural diff implementation. +- Packages 1–13 are implemented in `packages/durable`; Package 10 was already satisfied by Chord's canonical structural diff implementation. ## 1. Records, cursors, and memory tables @@ -287,12 +287,20 @@ Cross-cutting constraints for these milestones: ## 13. Openable Harness -Implement the first usable slice of the final §2.2 surface: -`Harness.open/close`, stable root creation and lookup, conversation lookup and -creation, concrete-entry forks, `onConversation`, conversation-bound `commit()` -and `entries()`, and generic inherited Session document APIs. Implement the +Implement the first usable slice of the final §2.2 surface: `Harness.open/close` +with a registry, `root(context, { init })`, `conversation(id)`, +`createConversation({ ownership, init })`, and generic +inherited Session document APIs (`Harness extends Session`). Implement the public `Entry` definition token and `Conversation` handles rather than adding an -intermediate capability facade. +intermediate capability facade. `Conversation` provides `id`, conversation-bound +`commit()`, fork-aware cursor `entries()`, `context()`, `fork(at, { ownership, +init })`, and every configuration getter/setter. + +Implement the §7.1 registry core: `createRegistry()`, `tools.add`, `tools.wrap` +composition, `batch`, keyed ordering with remembered positions, `Registration` +`dispose`, immutable `snapshot()`, and list helpers. Hook, +task, and system prompt registrations are stored and listed now; their dispatch +lands in Packages 14–16. Bring conversation and entry semantics up to the normative contract: explicit ownership, fork-aware cursor pagination, head lookup, entry edits, and model @@ -300,51 +308,47 @@ context derivation. Context reduction includes newest-edit wins, positional PR #9548 `SystemMessage` replay, tool-result ordering, missing post-fork tool results, excluded stop reasons, and separate raw-history/model-context results. -Define the final rewindable/as-of conversation configuration document now. It -contains model selection, default `"off"` thinking, ordered section values, and -active tool names. Implement every configuration getter/setter and atomic -configuration seeding/override needed by root, independent conversation, and -fork creation. Definitions supplied through final Harness options may be used -for section/tool identity validation; dynamic execution and hooks arrive later. -Do not create temporary fixed document accessors. +Define the final `ConversationConfig` document from §2.2. Raw document writes +are trusted and unchecked. Root creation uses the internal reserved-ID +bootstrap in one commit together with the default configuration and `init`; do +not expose a temporary root-creation API. -Use an internal final-form bootstrap transaction for reserved root ID `1`; do not -expose a temporary root-creation API or split empty-storage root/config creation -across commits. `options.root` applies only to empty storage. For this milestone, -Harness open/reopen acceptance is explicitly limited to storage with no live -tasks. Package 14 removes that limitation by adding complete open-time task -reconciliation; Package 13 must not invent partial reconciliation behavior. +Open ignores live tasks in this milestone; Package 14 adds reconciliation. Acceptance: open persistent storage, obtain the root, mutate configuration and history through public handles, create and fork conversations, close, reopen, -and verify stable root/conversation identity and state. Test atomic root and -conversation/config creation; actual forks; deep ancestor caps; same-commit -entry prefixes; cursor boundaries; self-head resolution; newest-edit wins; raw -head-to-tail transcript versus model context; model-less and excluded assistant -entries; replacements/omissions; multiple heads; section replacement/removal/ -re-addition order; integer-like section-key rejection; positional tool changes; -missing post-fork results; every configuration getter/setter; explicit active- -tool duplicate/unregistered rejection; default active registry snapshot; as-of -configuration inheritance including unavailable historical names; seed -overrides; listener initial/future delivery and isolation; and reopen. +and verify stable root/conversation identity and state. Test lazy root creation +with `init` run once; atomic create/fork with `init`; actual forks; deep ancestor +caps; same-commit entry prefixes; cursor boundaries; self-head resolution; +newest-edit wins; raw head-to-tail transcript versus model context; model-less +and excluded assistant entries; replacements/omissions; multiple heads; +positional system messages; tool-result ordering; missing post-fork results; +every configuration getter/setter; `setActiveTools` duplicate and newly-added +unregistered rejection with stale names allowed; default active tools from the +registry; as-of configuration inheritance including unavailable names; registry +add/dispose/batch rollback/nested-batch rejection/keyed order/wrap composition; +and reopen. ## 14. Durable task runtime Implement `defineTask`, exhaustive phase maps, full checkpoint replacement, -kind migration, runtime commits and memos, invocation lifetime gates, scheduler -reservation, dependencies, terminal outcomes, typed waits, holds, quiescence, -joins, and orphaning. Complete the task-facing §2.2 methods: `resume`, `suspend`, -`hold`, task-kind registration, `getTask`, `waitForTask`, `markTask`, -`abortTask`, and the task-aware portion of idle waits. +kind migration at reservation, runtime commits and memos, invocation lifetime +gates, scheduler reservation, dependencies, terminal outcomes, typed waits, +holds, quiescence, and joins. Complete the task-facing §2.2 methods: `resume`, +`suspend`, `hold`, `getTask`, `waitForTask`, `markTask`, `abortTask`, and the +task-aware portion of idle waits. Task definitions come from the registry snapshot; +add `RegistryReader.subscribe()` so registry changes wake the scheduler. Include the execution-critical abort core: durable direct-task marks, signal-and-join of an active run, fresh abort invocation, run-commit rejection after a mark, and close precedence. Deep owned-subtree cascading and background boundaries remain Package 18. Open now reconciles every surviving `running` task -to `pending`, migrates registered kinds, and atomically orphans unknown or -unmigratable live kinds as required by the normative specification. No handler -dispatches during open, and dynamic registration does not resurrect a task -settled by that pass. +to `pending`. Missing, too-old, and unmigratable definitions leave the task pending and +blocked (§5.4); registry changes wake the scheduler to reconsider them. Aborting +a blocked task settles it as `orphaned` with the full cleanup; nothing else +orphans a task. Implement per-phase registry snapshots, refresh at +every normal phase boundary, and hand over when the task definition object changed +and the new definition can reserve the task. Use a fake two-phase external effect to test the real runtime. Acceptance is an intent/effect/outcome task interrupted after intent, closed, reopened on the same @@ -353,8 +357,12 @@ checkpoint faulting, same-phase progress, cancellation precedence, thrown handlers, dependencies, result values and entry IDs, first-writer-wins memos, terminal checkpoint/memo removal, task-document retirement, close/reopen without abort marks or fabricated outcomes, no fresh phase/abort dispatch while closing, -watch cleanup, holds and quiescence with eligible work, unknown kinds, mark-only -versus signalling abort, and crashes at every direct-task abort stage. +watch cleanup, holds and quiescence with eligible work, blocked tasks unblocked +by later registration, migration failure leaving the record unchanged, handover +after a same-name task replacement, no handover to a missing or incompatible +definition, no overlap between predecessor and successor +invocations, abort of a blocked task settling as `orphaned` with full cleanup, +mark-only versus signalling abort, and crashes at every direct-task abort stage. ## 15. First runnable no-tool chat turn @@ -365,12 +373,14 @@ request-ID deduplication, reacquisition and waiting, idle placement, active-turn ownership, successful answer settlement, and terminal failure cleanup. Expose the final `SubmissionDraft` union rather than an interim input-only API. Complete the optional initial input path on conversation creation so conversation, -configuration, sections, and input admission commit atomically. Busy steer/ +configuration, `init` writes, and input admission commit atomically. Busy steer/ follow-up behavior, passive writes, and reset remain Package 17. -Implement section registration and no-tool request preparation, including exact -persisted rendered strings, positional system baselines/deltas, head-cut -rebaselining with `ContextEdit` omissions, and preparation revision checks. +Implement the §7.4 system prompt: rendering registered sections with `tag` and +wrappers, and no-tool request preparation, including +exact persisted rendered strings, positional system baselines/deltas with empty +`content`, head-cut rebaselining with `ContextEdit` omissions, order-only +two-entry rewrites, and section failure handling. Implement the ordinary generation phases needed for one response: preparation, request intent, durable throttled partials, attempts/retry classification, deferred handle polling/cancellation, assistant entry settlement, and input- @@ -388,36 +398,40 @@ interruption and reopen before/after every implemented generation phase, aborted-partial conversion, deferred polling/cancellation, retryable and terminal model errors, no-visible-undurable updates, exact section order/value patches, complete post-head baselines, retained system deltas on both sides of a -head marker, atomic input/ -configuration/section creation, and durable submission settlement. This is also -the first print-mode smoke path: print awaits its own input submission rather -than global idle. +head marker, sections reading conversation documents through `input.read`, +throwing sections, preparation +rerun after a concurrent head/tail/config commit, atomic input/configuration creation, and durable +submission settlement. This is also the first print-mode smoke path: print +awaits its own input submission rather than global idle. ## 16. First coding-agent tool turn -Implement task/tool registries and Session/owned-subtree hooks, then wire the -real generation → tool tasks → post-tools → generation chain. Implement offered- -set checks, declaration and argument validation, hook composition, durable -execution intent, stored replay policy, bounded stream/progress documents, -result entries, post-tools joining, controls, and `postTools`/`final` boundaries. -The generation task now classifies tool calls and continues through the real -built-in task chain; neither side uses a production fake successor. +Implement hook dispatch (Session-wide and scoped to a conversation or its owned +subtree) and wire the real generation → tool tasks → post-tools → generation +chain. Implement offered-set checks, tool pinning from the phase snapshot, +declaration and argument validation against both the offered declaration and +the pinned implementation, hook composition, durable execution intent, stored +replay policy, bounded output/details presentation documents, result entries, post-tools +joining, controls, and `postTools`/`final` boundaries. The generation task now +classifies tool calls and continues through the real built-in task chain; +neither side uses a production fake successor. -Implement runtime registration lifetimes and preparation behavior for tool -loadout additions/removals, same-name replacement ordering, complete baseline -tool declarations, hook memos, and configuration/registry revision retries. -Do not implement in-process replacement of executing Session-side extension -code; the normative v1 close/reopen boundary remains Package 20. +Implement preparation behavior for tool loadout additions/removals, same-name +replacement ordering, complete baseline tool declarations, wrapped tools and +failing wrappers, and hook memos. Acceptance: input → model tool call → registered local read/bash/edit operation → tool result → model answer → durable submission settlement. Run that path once normally and once interrupted/reopened. Test recovery from every tool and post- tools phase; offered-history enforcement; before/after hook rules; both stored/ -current replay-policy directions; default and overridden output bounds; streamed -content fallback; progress replacement and coalesced commit settlement; drain- +current replay-policy directions; default and overridden `outputLimits`; output +content fallback; details replacement, last-details fallback, and coalesced +commit settlement; drain- before-terminal ordering; abort/close with buffered output; invocation-bound -handles and watches; `missing_active_tool` settlement; atomic assistant/tool/ -post-tools commits; and all registry lifetime and positional tool-history cases. +handles and watches; unregistered active tools that are removed from the offered set, re-added after re-registration, and produce `tool_unavailable` results when called, without failing the request; a tool replaced mid-call +finishing under its pinned implementation, including across phase boundaries; +hooks surviving a task reload; duplicate active names offered once; order-only tool changes; atomic assistant/tool/post-tools commits; +and all positional tool-history cases. ## 17. Live UI and product state @@ -427,6 +441,11 @@ self-head cuts, successor turns, queued reset/handoff, and every terminal cleanu Successful inputs still require an answer; writes settle on placement and never start generation. +Specify and implement the Harness activity view: +the active conversations, notifications when a conversation becomes active or +idle, and Harness quiescence, all derived from committed turn-control and task +state. + Define any remaining built-in preference/presentation documents once with final schemas. Implement the structural `{ conversation, entries, docs }` `ConversationView`, `viewState()`, and `watch()`. Build the first revision lazily @@ -471,7 +490,7 @@ background boundaries, conversation abort/join with surviving passive writes and background tasks, cancellation of waiters without cancellation of work, and atomic cancellation intent. Test default non-inheritance, inheritance from the current committed tail, empty source conversations, document fork policies, -explicit model/section/tool seed overrides, foreground subagent cascade, and +configuration overrides through `init`, foreground subagent cascade, and background supervisor recovery before and after submission admission. ## 19. Collapse and overflow @@ -488,13 +507,10 @@ integration without a fake collapse kind. ## 20. Reload and final conformance -Complete any remaining §2.2 surface and lifecycle gates, then implement the -normative v1 host-extension reload path: stop admission/reservation, close and -join, dispose registrations/facets, rebuild over the same storage with new -definition tokens and task/tool/section definitions, reopen/migrate live tasks, -and resume. Ordinary documents migrate on later typed access. The separate live- -registries proposal remains non-normative unless it is first merged into -`pico-v5.md`; do not silently substitute it for §7.4. +Complete any remaining §2.2 surface and lifecycle gates and the §7.5 in-process +reload path through the registry: batch replacement while the Harness keeps +running, pinned in-flight work, and task handover at phase boundaries. +Close/reopen is not required for reload. Test that close seals commit and mutation admission, lets already-admitted storage settlement finish despite caller cancellation, stops future state/watch @@ -507,9 +523,9 @@ shutdown. Verify service withdrawal and client detach. Run the exhaustive public conformance matrix: stable persisted root identity; all root/create/lookup/fork/reset/collapse/abort/idle paths; every configuration getter/setter and fork override; typed input/write submissions; task wait/abort; -generic document access; task/tool/section registration between open and resume; -conversation listener isolation; structural watches; and no resurrection of a -task settled during open. Compile-test every §2.2 and §3 owner/key/seed overload, +generic document access; registry changes before open, between open and resume, +and while work runs; the activity view; structural watches; and +blocked tasks surviving open. Compile-test every §2.2 and §3 owner/key/seed overload, the normative usage sequences, and the Chord guide. The erased registry test must use a concrete narrowed-input task with multiple checkpoint phases and custom hooks. Verify that a Chord root-replacement delta remains distinct from a diff --git a/packages/durable/docs/pico-v5-live-registries.md b/packages/durable/docs/pico-v5-live-registries.md deleted file mode 100644 index e5ba6c096..000000000 --- a/packages/durable/docs/pico-v5-live-registries.md +++ /dev/null @@ -1,614 +0,0 @@ -# Pico5 live extension registries - -Status: proposed merge input for `pico-v5.md`. This document is not normative -until its contracts are incorporated into that specification. - -This proposal replaces Pico5's close-and-reopen extension reload path with the -smallest safe in-process mechanism: - -> New work switches to the replacement immediately. Work already in progress -> remains pinned to the code and resources with which it started until it -> actually settles. - -It covers tools, task kinds, and hooks. System sections, conversation listeners, -and arbitrary Chord service-provider reload are deliberately deferred. - -## 1. Problem - -The current Pico5 design treats Session-side extension code as part of one -Harness generation. Replacing a tool, hook, or task kind therefore requires the -host to close the Harness, join all invocations, reopen the same storage, and -resume from durable checkpoints. - -That fallback is safe but unnecessarily disruptive. Changing one tool can -interrupt unrelated generations, tools, and child conversations. - -Replacing callbacks directly in mutable maps is not safe either: - -- one invocation can start with an old task kind and finish through new hooks; -- a failed facet activation can expose only part of its replacement; -- disposing an old facet can close resources still used by its callbacks; -- interrupting an effect merely because code changed creates an uncertain-effect - recovery case; -- arbitrary JavaScript cannot be forcibly terminated in-process. - -The required behavior is generation pinning, not callback mutation. - -## 2. Scope and non-goals - -This proposal provides: - -- application-owned extension registries passed to `Harness.open()`; -- one atomic contribution generation per extension owner; -- per-key lookup for tools, task kinds, and hooks; -- gapless replacement for new work; -- generation pinning for work already in progress; -- automatic Chord facet registration cleanup; -- compatible task-kind handover at normal durable phase boundaries; -- observable draining of old generations. - -It does not provide: - -- forced termination of arbitrary JavaScript; -- a deadline after which work is declared complete; -- detached or fabricated callback settlement; -- cancellation merely because an implementation was replaced; -- `aroundTool` or `aroundPhase` wrappers; -- transparent wrapper unrolling; -- cross-owner overrides of named tools or task kinds; -- atomic replacement of several independent extension owners; -- hot replacement of the Harness core, storage, or the Harness Chord provider; -- general generation pinning for arbitrary Chord service graphs. - -Harness-core and storage changes continue to use close and reopen. Process or -worker termination remains the escape for non-cooperative code. - -## 3. Terms and invariants - -- An **owner** is the stable identity of one extension contributor. -- A **generation** is one immutable complete contribution set published by an - owner. -- A **snapshot** is an invocation-local registry view. It lazily pins owner - generations as they are first touched. -- A **pin** keeps one retired generation and its facet resources alive. -- **Cutover** atomically publishes a candidate generation and retires its - predecessor. -- **Drainage** occurs when a retired generation has no pins. -- A **parked facet** has retired its contributions but retains its ordinary - resources until its generation drains. - -Required invariants: - -1. Every execution of registered code belongs to one pinned owner generation. -2. New independent invocations never acquire a retired generation. -3. Once an invocation pins owner `X`, every later lookup for `X` in that - invocation, including a nested tool or hook call, uses the same generation - and sees the same missing contributions. -4. Replacement never signals or interrupts existing work. -5. A retired generation admits no new independent acquisition or contribution; - existing pinned invocations retain access to it. -6. Facet resources outlive every invocation using that facet generation. -7. Failed candidate activation publishes no contribution and changes no task. -8. A task changes implementation only after its current phase handler and every - accepted invocation-owned operation have settled. -9. No successor invocation runs concurrently with its predecessor for the same - task. -10. Storage-admitted commits settle before task handover or storage close. - -## 4. Registry surface - -The application constructs one registry set and passes it to the Harness: - -```ts -type HookHandler = (...args: never[]) => unknown; - -type HookLocation = { - readonly conversationId: Id; - readonly ownershipAncestors: readonly Id[]; -}; - -type HookContribution = { - readonly task: string; - readonly hook: string; - readonly handler: HookHandler; - readonly scope?: { - readonly conversationId: Id; - readonly subtree?: boolean; - }; -}; - -type ExtensionContributions = { - readonly tools?: readonly ToolRegistration[]; - readonly taskKinds?: readonly AnyTask[]; - readonly hooks?: readonly HookContribution[]; -}; - -type ExtensionGeneration = { - readonly owner: string; - readonly sequence: number; - readonly state: "current" | "retired" | "drained"; - readonly pins: number; - retire(): void; - readonly drained: Promise; -}; - -interface ExtensionSnapshot { - tool(name: string): ToolRegistration | undefined; - taskKind(name: string): AnyTask | undefined; - hooks(task: string, hook: string, at: HookLocation): readonly HookHandler[]; - release(): void; -} - -interface ExtensionRegistries { - replace( - owner: string, - contributions: ExtensionContributions, - ): ExtensionGeneration; - - /** Harness-facing read surface; application code does not call this. */ - snapshot(): ExtensionSnapshot; - subscribe(listener: () => void): () => void; -} - -type HarnessOptions = { - readonly models: Models; - readonly registries: ExtensionRegistries; - // Existing options not replaced by this proposal remain here. -}; -``` - -`replace()` performs all validation before publication. Publication is -synchronous and invokes no extension callback. - -Built-in tools and task kinds remain Harness-core tables outside the extension -registries. They change only across a close/reopen core generation and are never -pinned, drained, or superseded through this API. `replace()` rejects an extension -candidate whose tool or task-kind name collides with a built-in. Harness lookup -combines the immutable core table with the extension snapshot. - -One owner has at most one current generation. Replacing that owner atomically: - -1. publishes the candidate complete contribution set; -2. makes it current for new snapshots; -3. retires the predecessor; -4. notifies registry subscribers. - -The predecessor remains usable through existing pins. Calling its exact -`retire()` again is idempotent and never withdraws its successor. - -### 4.1 Names and collisions - -Tool names and task-kind names are unique across current owners. A candidate that -collides with another owner rejects before publication. Same-owner replacement is -allowed and gapless. - -Hooks are additive across owners. Their existing hook-specific composition and -ordinary-error rules remain defined by Pico5 section 7.1. Hook order is stable -owner order followed by declaration order within that owner's generation; an -owner reload does not move that owner's slot. - -Cross-owner overrides are not supported initially. They can be added later as an -explicit host policy rather than warning-only executable substitution. - -### 4.2 Internal snapshots - -The Harness opens one internal registry snapshot for each scheduler task -invocation. Tool execution, request preparation, and every hook question nested -inside that invocation share it. Nested runtime operations inherit its existing -owner pins rather than opening an unrelated view. An independent host operation -may open its own snapshot. Application code does not manage snapshots or pins. - -A snapshot lazily pins owners: - -```text -create snapshot -first lookup resolved to owner A → pin A's current generation -later lookup for owner A → use that pinned generation -first lookup resolved to owner B → pin B's then-current generation -invocation fully settles → release A and B -``` - -Owners never touched are not pinned. Several retired generations of one owner may -drain concurrently. - -A snapshot releases its pins only after: - -- the callback's real promise settles; -- accepted invocation-bound runtime operations settle; -- invocation-owned watches stop and their active callbacks settle. - -The registry never substitutes its own promise, detaches a callback, or claims -that a callback ended before its real settlement. - -## 5. Registry readers - -The Harness owns composition. The registries only provide generation-consistent -lookup. - -### 5.1 Tools - -Request preparation resolves declarations through the generation invocation's -snapshot using the existing durable active-tool names. A missing active name -retains Pico5's `missing_active_tool` behavior. - -A tool task resolves and pins one implementation before execution-schema -validation and durable effect-intent admission. That one definition supplies its -schema, replay policy, and `execute()` function and remains pinned through task -settlement. If the exact name is no longer registered although the stored -`pi.system` history proves it was offered, the task appends an error tool result: - -```ts -{ code: "tool_unavailable" } -``` - -The turn continues so the model can react. - -Arguments must satisfy both the declaration offered to the model and the current -pinned implementation's schema. An incompatible replacement produces an error -tool result without executing replacement code. - -Replacing a tool while it runs does not interrupt it. Its result is produced by -the implementation it pinned. The next independent tool invocation uses the -replacement. - -### 5.2 Hooks - -Each hook question captures its complete applicable handler list from the -scheduler invocation's shared snapshot before running its first handler. -Replacement cannot change the remaining chain midway. - -A generation preparation attempt also uses that invocation snapshot. Registry -movement alone does not restart preparation: the exact pinned declarations and -handlers remain authoritative for that attempt, and the next independent -invocation sees replacements. Existing retry rules for durable configuration or -document revision changes remain, but those retries reuse the pinned extension -snapshot. - -Conversation subtree matching uses the conversation ownership chain, not the -history-parent chain. - -Existing Pico5 composition and error rules remain unchanged. This proposal adds -no wrapper hooks and no special revocation errors because ordinary replacement -never signals a handler. - -`Conversation.hooks()` may remain as a convenience that publishes one scoped -hook-only owner generation and retires it through the returned disposer. - -### 5.3 Task kinds - -A scheduler invocation pins one task kind for its complete lifetime, including -successive phase handlers until a compatible handover boundary is reached. -Running tasks are never migrated in place. - -## 6. Task-kind replacement - -### 6.1 Continuation contract - -Every successful, progress-making phase-handler settlement is already required -to leave a complete durable checkpoint. Live replacement strengthens that rule: - -> A normal phase boundary is resumable from durable task state, documents, and -> durably identified external operations. Continuation does not depend on the -> retiring invocation's local variables or unfinished untracked work. - -Registering the same task version is an explicit promise that persisted input, -checkpoint, memos, and continuation semantics remain compatible. - -A higher version must provide `migrate()`. Its successful migration is the -compatibility proof for input and checkpoint. Memos pass through unchanged in -this initial design. A replacement that changes memo meaning is incompatible and -must not claim takeover compatibility. - -Task-document migration remains access-driven and separate. - -### 6.2 Normal automatic handover - -Replacement never interrupts a phase handler. After one handler settles, the -scheduler applies existing rules first: - -1. terminal task; -2. Session closing; -3. durable abort mark; -4. uncaught error; -5. no durable progress. - -Only an ordinary continuation boundary is eligible for handover. - -If the invocation's pinned kind generation is still current, execution continues -normally. - -If it was superseded: - -- the same task version is eligible by declaration; -- a higher version is eligible only when it provides `migrate()`; -- no current kind of that name is incompatible; -- an older current version is incompatible; -- a higher version without migration is incompatible. - -Eligibility does not run migration as a preflight. Actual migration runs later in -the successor's reservation transaction against the latest drained checkpoint. -Migration failure then leaves the task pending and blocked without modifying its -durable payload. - -For an eligible successor, the Harness: - -1. ends the old invocation and rejects further invocation-bound operations; -2. stops future delivery to its owned watches; -3. joins accepted runtime operations; already-running watch callbacks remain caller-owned; -4. lets storage-admitted commits settle normally; -5. rereads the task on the Session line and reapplies terminal, closing, abort, - error, and no-progress precedence; -6. only for an ordinary continuation, persists the task as `pending`, preserving - its latest checkpoint, memos, and abort mark; -7. releases the old generation pin. - -The successor is a fresh invocation. Reservation selects and pins the current -kind. If migration is required, migration and `pending → running` commit -atomically before dispatch. - -No public `requestTakeover()` API is needed. A handler that never settles never -transfers. - -### 6.3 Incompatible replacement - -If the current kind is incompatible, the task continues under its pinned old -generation and reports that condition once per task and replacement generation. -Handover is reconsidered at its next normal boundary. - -This keeps useful work running rather than stranding it merely because an -incompatible candidate was published. The old facet remains parked while the pin -exists. - -### 6.4 Reservation and stalled tasks - -Every invocation start, including a fresh abort invocation, resolves the kind -through a new snapshot. - -```text -kind missing - → pending, blocked: missing_kind - -stored version newer than current kind - → pending, blocked: kind_too_old - -stored version older than current kind - → run pure migrate(input, checkpoint, storedVersion) - → failure: pending, blocked: migration_failed - → success: commit migrated input/checkpoint/version + running atomically - -equal version - → commit running -``` - -A live Session never automatically terminalizes a task solely because registry -code is missing or incompatible. A blocked task preserves its abort mark, remains -live, continues to block ordinary idle waits, and is retried when -`ExtensionRegistries` changes. The blocked reason and both versions are exposed -as derived task availability, not persisted as a new task state. - -Open-time reconciliation changes surviving `running` tasks to `pending` but does -not orphan unknown or unmigratable kinds. The same reservation rules handle them -as temporarily blocked, so extension publication may occur before or after -`Harness.open()` without destroying work. Permanent extension removal requires -an explicit host operation: - -```ts -orphanTask( - id: Id, - context: Context, -): Promise<"orphaned" | "terminal" | "not_blocked">; -``` - -It terminalizes a selected blocked task as `orphaned` and performs the existing -submission, turn-control, task-document, and diagnostic cleanup atomically. -Merely opening the Harness never makes that irreversible decision. - -`Conversation.abort()` and `waitForIdle()` remain pending while a non-background -task is blocked. `orphanTask()` is the escape and may be called while either wait -is pending. - -## 7. Chord facet integration - -Facet authors should not receive or retain registration disposers. The Chord host -provides one local consumer-bound extension service. Its methods stage the -consumer facet's complete contribution set. - -Conceptually: - -```ts -setup(env) { - const extensions = env.use(Extensions); - - env.onActivate(() => { - extensions.tool(grep); - extensions.taskKind(indexTask); - extensions.hooks("tool", { beforeTool, afterTool }); - }); -} -``` - -The service implementation knows the consuming facet's stable owner ID and -lifecycle. Registration ownership therefore cannot be forgotten. - -### 7.1 Candidate activation - -A candidate facet generation stages contributions during activation. They remain -invisible until candidate activation succeeds and every named collision is -validated. Acquiring the guarded service handle during setup does not publish or -invoke it. - -At successful cutover, Chord calls one publication closure. That closure invokes -`ExtensionRegistries.replace(owner, stagedContributions)`. Failed candidate -activation discards the staging area and publishes nothing. - -After cutover: - -- new Harness snapshots acquire the candidate generation; -- old snapshots retain the predecessor; -- the predecessor's contribution capability is sealed; -- late registration from the predecessor rejects before changing a registry. - -### 7.2 Retirement barrier - -Cutover retires the predecessor generation immediately but does not dispose the -predecessor facet's ordinary resources. - -Chord parks the old facet record. Ordinary resource disposal begins only after -all of that generation's registry pins drain. This protects sockets, timers, -subscriptions, stable dependency handles, and mutable service objects still used -by old callbacks. Parked dependency handles remain usable until drainage, and -host shutdown joins parked records before disposing those stable providers. - -Initially, hot-reloadable contribution facets may depend only on stable -providers. Pinning or rebinding arbitrary provider generations is deferred. - -`reload()` resolves after successful activation, publication, and predecessor -retirement. It does not mean the predecessor has drained or been disposed. -Retirement status and pin count remain observable for diagnostics. - -Host shutdown joins both active and parked records. Non-cooperative work can -therefore prevent graceful shutdown from completing. - -### 7.3 Minimum Chord lifecycle capability - -The exact Chord API may follow its existing naming, but it must support these -semantics for local consumer-bound services: - -1. create one guarded service view per consuming facet generation; -2. stage contributions until successful activation cutover; -3. synchronously retire the published contribution generation at replacement or - unmount; -4. receive its `drained` promise as a retirement barrier; -5. park the old facet record until the barrier settles; -6. only then run ordinary facet disposers and release the old bundle. - -The Harness extension provider itself remains stable and is not hot reloaded in -this first version. General provider replacement and transitive dependency -pinning remain outside this proposal. - -## 8. Non-cooperative work and close - -An invocation that never settles keeps its pinned generation and facet resources -alive. New work still uses the current replacement, so ordinary reload is not -blocked. - -The registry does not pretend the old invocation stopped. It does not release -resources, write a fabricated interruption result, or permit a successor for the -same task to run concurrently. - -Harness close retains Pico5's existing semantics: - -1. seal mutation admission and task reservation; -2. signal active invocations and stop watches; -3. await invocations, watch callbacks, and admitted storage commits; -4. close sources and storage. - -Close may remain pending on non-cooperative work. The safe operational fallback -is terminating the owning worker or process and reopening from durable state. -Old and new Harness instances must never own the same Session concurrently. - -## 9. Resolved policy choices - -This proposal resolves the recovered handoff's open decisions as follows: - -1. **Deadlines:** none. Replacement is synchronous; real work drains - asynchronously. Graceful close remains unbounded. -2. **Same-name contributions:** same-owner replacement is allowed; cross-owner - named tool/task collisions reject; hooks are additive. -3. **Blocked tasks:** remain durable `pending`; availability is derived and - visible; as live non-background tasks they continue to block ordinary idle - waits until code returns or the host explicitly orphans them. -4. **System sections:** remain on the existing Pico5 surface for now. -5. **Revocation errors:** none are added because replacement does not cancel - callbacks. -6. **Conversation-scoped hooks:** live until their explicit owner generation is - retired. Collapse and reset do not retire them. -7. **Memos:** preserved unchanged. Memo transformation is deferred and a version - change requiring it is incompatible. - -## 10. Required tests - -### Registry and snapshots - -- Same-owner replacement has no lookup gap. -- Cross-owner and built-in tool/task-kind collisions reject before publication. -- An old generation's `retire()` cannot withdraw its successor. -- One invocation uses one coherent generation for every contribution from an - owner, including a missing contribution. -- Separate owners are pinned lazily and released after real invocation - settlement. -- Several retired generations of one owner may drain independently. - -### Tools and hooks - -- A tool replaced mid-call finishes once under its old implementation; the next - call uses the replacement. -- A removed execution-time tool produces `tool_unavailable` and the turn - continues. -- Incompatible current arguments never enter replacement tool code. -- A hook chain captured before replacement completes under its captured owner - generations and existing composition rules. -- Scoped hooks follow ownership ancestry rather than history ancestry. - -### Tasks - -- Same-version task replacement transfers only after a normal progress boundary. -- A version bump migrates and reserves atomically. -- Migration failure leaves durable input, checkpoint, memos, and version - unchanged and leaves the task visibly blocked. -- Terminal, close, abort, error, and no-progress rules take precedence over - replacement. -- An incompatible replacement lets the old pinned kind continue and reports - once. -- A non-settling handler never transfers and prevents its generation from - draining. -- No successor invocation overlaps its predecessor. -- Registering a missing compatible kind wakes the scheduler. -- Reopen with a missing kind blocks rather than orphaning the task; conversation - abort and idle waits remain pending until an explicit host orphan operation - performs the complete terminal cleanup. -- A fresh abort invocation applies the same compatibility gate. - -### Chord lifecycle - -- Failed candidate activation publishes nothing. -- Successful reload returns after cutover without waiting for drainage. -- New work uses the candidate while old work retains all predecessor resources. -- Late predecessor registration rejects without leaking a contribution. -- Facet authors need no registration disposer. -- Ordinary facet disposal occurs only after registry drainage. -- Host shutdown joins parked records. - -### Storage and close - -- Accepted runtime operations and storage commits settle before handover. -- Invocation-bound APIs reject after invocation end. -- Close never closes storage beneath an admitted commit. -- Forced process restart recovers from the latest durable checkpoint without - relying on process-local registry generations. - -## 11. Normative integration map - -If adopted, merge the contract into `pico-v5.md` coherently rather than making -implementers combine two normative documents: - -- **§2.2:** pass `ExtensionRegistries` through `HarnessOptions`; remove Harness - tool/task registration ownership; retain existing section/listener surfaces. -- **§2.2 and §5.4:** replace automatic open-time unknown-kind orphaning with - derived blocked availability and an explicit host orphan operation. -- **§5.1 and §5.4:** pin task kinds per invocation; add compatible boundary - handover and reservation-time migration. -- **§7.1 and §7.2:** define inherited hook snapshots and tool implementation - pinning before validation and intent admission. -- **§7.3:** retain document/configuration revision retries but remove registry - movement as a retry trigger within one pinned generation invocation. -- **§7.4:** replace close/reopen for ordinary extension changes with live - owner-generation replacement; retain close/reopen for core and storage. -- **§9.2:** include invocation-owned watch drainage in pin release and handover. -- **§13:** remove the non-goal excluding in-process replacement of Session-side - extension implementations, while retaining the process-isolation caveat. -- **Chord facet specification:** add candidate staging and retirement barriers for - the stable Harness extension service. - -After those normative edits land, this file may remain as rationale or be -removed to avoid two sources of truth. diff --git a/packages/durable/docs/pico-v5.md b/packages/durable/docs/pico-v5.md index c51e1caa9..3b85adc16 100644 --- a/packages/durable/docs/pico-v5.md +++ b/packages/durable/docs/pico-v5.md @@ -120,11 +120,14 @@ interface SystemMessage { } ``` -`content` is the base prompt on the leading message and additional instruction -text on later messages. `sections` is an ordered named patch: a string adds or -replaces a section, while `null` removes it. `toolsRemoved` is applied before -`toolsAdded` within one message. Replaying every system message in transcript -order yields the effective prompt and tool set. +In pi-ai, `content` is the base prompt on the leading message and additional +instruction text on later messages. `sections` is an ordered named patch: a +string adds or replaces a section, while `null` removes it. `toolsRemoved` is +applied before `toolsAdded` within one message. Replaying every system message +in transcript order yields the effective prompt and tool set. + +Pico always writes `content: ""`. Every prompt part, including the preamble, is +a named section produced by the registry's system prompt slot (section 7.4). ```ts type ContextEdit = { @@ -256,9 +259,11 @@ Context derivation: 5. If `H` exists, context entries are `H` followed by non-head entries in the range. Otherwise they are the range. 6. Keep every positional system message and its tool/section changes. -7. Order tool results by assistant tool-call order. -8. Synthesize missing tool results after a fork when required by the provider - message protocol. +7. Move each assistant's tool results, found before the next assistant message, + directly after it in tool-call order, ahead of any intervening user or system + message. The provider protocol requires results to follow their call. +8. Synthesize an error result for every call without one, for example after a + fork cut or at an interrupted tail. Drop tool results with no preceding call. 9. Exclude model-less entries and assistant messages with `aborted`, `error`, or `deferred` stop reasons from future provider requests. @@ -299,18 +304,12 @@ type SubmissionDraft = { type InputSubmissionDraft = Extract; -type SectionSeed = - | { readonly key: string; readonly value: JsonValue } - | { readonly key: string; readonly remove: true }; +/** Runs inside the creating commit, after the conversation and its configuration exist. */ +type ConversationInit = (tx: Tx, conversationId: ConversationId) => void | Promise; -type ConversationSpec = { - readonly model?: ModelRef; - readonly sections?: readonly SectionSeed[]; - readonly activeTools?: readonly string[]; -}; - -type ConversationCreateSpec = ConversationSpec & { +type ConversationCreateOptions = { readonly ownership: ConversationOwnership; + readonly init?: ConversationInit; }; type AnyTask = { @@ -325,16 +324,22 @@ type AnyTask = { }; }; -type HarnessOptions = { +type HarnessOptions = { readonly models: Models; // the pi-ai Models interface - readonly tools?: readonly ToolRegistration[]; - readonly taskKinds?: readonly AnyTask[]; - readonly sections?: readonly SystemSection[]; + readonly registry: RegistryReader; // section 7.1 readonly now?: () => number; - readonly root?: ConversationSpec; readonly onReport?: (error: unknown) => void; }; +type ConversationConfigState = { + model?: ModelRef; + thinkingLevel: ModelThinkingLevel; + activeTools: string[]; +}; + +/** Built-in rewindable configuration document; see below. */ +declare const ConversationConfig: RewindableConversationDocToken; + interface Entry { readonly kind: string; is(entry: EntryRecord | undefined): entry is E; @@ -379,15 +384,6 @@ interface Conversation { setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise; getActiveTools(context: Context): Promise; setActiveTools(names: readonly string[], context: Context): Promise; - getSection( - section: SystemSection, - context: Context, - ): Promise; - setSection( - section: SystemSection, - value: T | undefined, - context: Context, - ): Promise; commit( change: (tx: Tx) => T | Promise, @@ -402,19 +398,13 @@ interface Conversation { ): Promise>; fork( at: EntryId, - spec: ConversationCreateSpec, + options: ConversationCreateOptions, context: Context, ): Promise; collapse(instructions: string | undefined, context: Context): Promise; reset(handoff: string | undefined, context: Context): Promise; abort(context: Context): Promise; waitForIdle(context: Context): Promise; - hooks( - owner: string, - task: K, - handlers: Partial>, - options?: { readonly subtree?: boolean }, - ): () => void; viewState(context: Context): Promise>; watch(context: Context): Promise; } @@ -425,20 +415,13 @@ interface Harness extends Session { quiescent(): boolean; hold(): () => void; - registerTaskKind(task: AnyTask): () => void; - registerTool(tool: ToolRegistration): () => void; - registerSection(section: SystemSection): () => void; - hooks( - owner: string, - task: K, - handlers: Partial>, - ): () => void; - - root(context: Context): Promise; - onConversation(listener: (conversation: Conversation) => void): () => void; + root( + context: Context, + options?: { readonly init?: ConversationInit }, + ): Promise; conversation(id: ConversationId, context: Context): Promise; createConversation( - spec: ConversationCreateSpec & { readonly input?: UserInput }, + options: ConversationCreateOptions & { readonly input?: UserInput }, context: Context, ): Promise; @@ -456,9 +439,9 @@ interface Harness extends Session { } declare const Harness: { - open( + open( storage: Storage, - options: HarnessOptions, + options: HarnessOptions, context: Context, ): Promise; }; @@ -471,20 +454,23 @@ conversation watch replace those surfaces. `submit()` durably admits either a user input or passive entry write and returns one `Submission` that tracks its settlement. -`Harness.open()` installs built-in task, tool, and section definitions followed -by supplied task kinds, tools, and sections. It changes -surviving `running` tasks to `pending`, migrates task records, and settles unknown -or unmigratable live task kinds as `orphaned` before resolving. Any built-in -document touched by that recovery migrates through its ordinary typed access -path. Open does not scan or migrate other documents. IDs and names in registries must be unique. No -handler dispatches during open. Dynamic registration is available after open and -does not resurrect a task already settled by that pass. +`Harness.open()` binds the Session to one application-owned registry (section +7.1). Built-in task definitions live outside that registry. +Open changes surviving `running` tasks to `pending` and does nothing else to +task records: it never migrates or terminalizes a task because its definition +is missing or unmigratable. Such a task stays `pending` and is **blocked**: the +scheduler skips it and reconsiders it whenever the registry changes (section +5.4). Any built-in document touched by recovery migrates through its ordinary +typed access path. Open does not scan or migrate other documents. No handler +dispatches during open. Applications should register tasks before open so +recovered work can resume immediately; registration after open also unblocks it. -The root conversation always has reserved ID `ROOT_CONVERSATION_ID` (`1`). Empty -storage creates that conversation from `options.root` as ownerless; reopen looks -it up by the reserved ID. `options.root` never overwrites existing state. `root()` returns -that handle. A conversation with no configured model produces a durable -`no_model` generation failure. +The root conversation always has reserved ID `ROOT_CONVERSATION_ID` (`1`). +`root(context, { init })` creates it lazily: when the root is absent, one commit +on the Session line creates the ownerless root, its default configuration, and +runs `init(tx, rootId)`. When the root already exists, `init` is ignored and no +write occurs. Reopen finds the same root by its reserved ID. A conversation with +no configured model produces a durable `no_model` generation failure. `resume()` is idempotent while running and only enables scheduling. It does not repeat open-time reconciliation. `suspend()` is terminal for that Harness @@ -493,57 +479,78 @@ rejects. `quiescent()` means no task invocation is currently executing; eligible or delayed durable tasks may still exist. `hold()` is available only while quiescent and pauses reservation until its idempotent release function runs. -Runtime registration rejects duplicate IDs/names and returns an idempotent -function that unregisters only that exact token. It supports declaration -movement during normal operation, not replacement of executing extension code; -section 7.4 governs code reload. +`createConversation({ ownership, init, input })` and +`fork(at, { ownership, init })` commit atomically: the conversation, its +explicitly selected ownership, its configuration, every write made by `init`, +and an optional input submission. Host callers must choose ownerless or task +ownership; neither the Harness nor a conversation handle infers ownership from +call context. A new independent conversation receives the default configuration +before `init` runs. A fork receives the configuration visible at `at` through +the ordinary `asOf` fork policy; `init` may then override it with +`tx.doc(ConversationConfig, id)`. Other documents follow their own definitions +without special handling. -Conversation creation atomically commits the conversation, its explicitly -selected ownership, built-in configuration and section/tool seeds, and an -optional input submission. Host callers must choose ownerless or task ownership -in `ConversationCreateSpec`; neither the Harness nor a conversation handle -infers ownership from call context. For a fork, omitted model, section, and -active-tool values follow their document definition's fork policy; provided -values override those forked built-in values in the same commit. Other documents -follow their own definitions without special handling. +Raw `tx.createConversation()` and `tx.forkConversation()` remain available. +They write no configuration of their own; a conversation without a configuration +document reads as `ConversationConfig.definition.initial()` until something +writes it. Callers that want Harness defaults use the Harness conveniences or +write the document in the same commit. -The built-in conversation configuration document contains the selected model, -thinking level, an ordered array of section key/value records, and active tool -names. Its initial thinking level -is `"off"`. It is rewindable with `fork: "asOf"`, so a child starts from the -configuration visible at its selected entry unless explicit creation seeds -override it. Conversation creation eagerly -creates it, so `getModel()`, `getThinkingLevel()`, `getActiveTools()`, and -`getSection()` return immutable committed snapshots without a get-or-create -write. Each setter performs one ordinary Session commit against that document; -`setSection(section, undefined)` removes the value. A setter does not start +The built-in conversation configuration document is final at version 1: + +| field | value | +|---|---| +| kind | `pi.conversation.config` | +| version | `1` (no migration) | +| scope/history/fork | conversation, `rewindable`, `asOf` | +| schema | `ConversationConfigState` | +| `initial()` | `{ thinkingLevel: "off", activeTools: [] }` | +| checkpoint | complete base on every change | +| view mount | `docs["pi.conversation.config"]` | + +It has no prompt sections: prompt text is produced per request by the registry +(section 7.4). Any code may edit it with +`tx.doc(ConversationConfig, id)`, including in the same commit as a create or +fork. The getters return immutable committed values, falling back to +`initial()` when the document is absent; they never write. Each setter performs +one ordinary Session commit against the document. A setter does not start generation or append a system entry. Request preparation later compares the -desired committed configuration with transcript history and appends the required +desired configuration with transcript history and appends the required positional system baseline or delta. -Tool declarations and executable functions are process-local Harness registry -entries, supplied through `HarnessOptions.tools` or `registerTool()`. The durable -configuration document stores only active tool names. A new independent -conversation defaults to every tool registered when its creation is admitted; -`ConversationSpec.activeTools` overrides that default. A fork with no explicit -`activeTools` uses the configuration document's `asOf` value. +Default active tools for a root or independent conversation are the names of +every tool registered when its creation commit runs, in registry order. +`setActiveTools()` rejects the whole operation without a write when a name is +duplicated or when a name that was not already active in that conversation is +not registered. The create/fork conveniences check only one thing about `init` +writes: names that `init` newly activates must be registered. Names that were +already active are never revalidated, so stale unregistered names survive every +edit. All other raw document writes are trusted and unchecked, so other writers, +such as post-tools `addTools`, never fault because of registry movement. Readers tolerate what raw +writes can produce: request preparation offers the first occurrence of a +duplicated name, and post-tools `addTools` appends only names not already +active. -`registerTool()` changes only the runtime registry. It does not activate the tool -in existing conversations or write a document. `setActiveTools()` is the only -host operation that replaces one existing conversation's durable active set. -Explicit `ConversationSpec.activeTools` and `setActiveTools()` values must contain -unique names that are registered when their commit is admitted; otherwise the -whole operation rejects without a write. Inherited historical names are not -revalidated during a fork and may later be unavailable after registry movement. -Unregistering a tool does not rewrite any conversation. +`init` runs after the conversation creation, which is a table write, so table +reads inside it throw `ReadAfterWrite` (section 4). Document access remains +available. -If request preparation finds unavailable active names, it performs no provider -request. It atomically terminalizes the generation task as `failed` with -`detail: { code: "missing_active_tool", names }`, makes its placed input submissions -`unanswered` with reason `missing_active_tool`, clears matching turn control, and -appends a visible model-less diagnostic entry. It does not silently change the -durable loadout. Historical system entries remain replayable because they store -the exact declarations actually offered to prior requests. +The configuration document records the desired loadout; the registry records +what this process can execute now. An active name may therefore be unregistered, +for example during extension reload, after restart before registration, or in a +fork opened by a process that lacks an extension. The document is never +rewritten because of registry movement, so a re-registered tool becomes available +again without host action. Hosts derive current availability as active names +that are currently registered. + +A missing tool implementation never fails a request. Request preparation offers +only active names that are currently registered. If the replayed tool state still +offers an unregistered name, the appended system delta lists it in +`toolsRemoved`; when the name is registered again, a later delta adds its current +declaration. When the model calls a tool that is not offered or whose +implementation is unavailable at execution time, the tool task appends an error +tool result with `details: { code: "tool_unavailable" }` stating that the tool is +not available, and the turn continues so the model can react. A `Conversation.commit()` is a Session commit bound to that conversation. `tx.createTask()` defaults `TaskOptions.conversationId` to the bound conversation. @@ -572,11 +579,11 @@ same traversal from every ownerless conversation root. Pending dependency- or deadline-blocked work is still live and therefore not idle. Cancelling an idle wait aborts only that waiter. -`onConversation()` synchronously visits the currently loaded committed -conversations in ascending ID order, then reports each later creation after its -commit. Listener failures go to `onReport` and do not stop other listeners. Its -idempotent disposer removes the listener; Harness close removes all remaining -listeners. +Conversation handles are stateless; compare them by `id`. Hosts discover +conversations through lookups and scans. An activity view that lists active +conversations and reports conversations becoming active or idle, plus Harness +quiescence, is specified with the task runtime and turn control (Packages 14 +and 17); there is no creation listener. `submit()` returns after durable admission, not settlement. An input submission creates a user message with the admission timestamp; `whenBusy` defaults to @@ -590,7 +597,8 @@ reopen; records remain queryable after settlement. `abortTask()` durably requests cancellation and returns `marked` after the mark is committed, any active run invocation has joined, and an abort invocation has -been scheduled; it does not await terminal settlement. `waitForTask()` observes +been scheduled, or a blocked task has been settled as `orphaned` (section 5.4); +it does not await terminal settlement. `waitForTask()` observes the terminal receipt. Aborting an already terminal task returns `terminal`; an unknown ID rejects. Explicit task abort includes a background task. Cancelling a task or idle wait does not abort work. @@ -1127,7 +1135,10 @@ Harness open does not sweep ordinary documents. - Read-only access migrates only in memory and never writes. It may cache a tracker over that migrated immutable revision together with the older stored version marker; the next successful `tx.doc()` access still writes the required - current-version base. Migration always starts from a detached stored value. + current-version base. A cached tracker serves only tokens of the version its value was + materialized for. Access with any other version, such as a token from reloaded + extension code, drops it and reloads the stored value, so each token migrates + from the stored version or rejects a newer one. Migration always starts from a detached stored value. `tx.doc()` stages migration in its enclosing transaction; callback failure persists nothing, and later draft edits coalesce into one final required base. - Rewindable history is not rewritten. Current and historical reconstructed @@ -1362,9 +1373,11 @@ settles, the scheduler rereads the task and applies the first matching rule: was made. On open, running-task reconciliation changes surviving `running` tasks back to -`pending`, preserving their checkpoint and abort mark. Task migration then runs -before dispatch. One callback handles every supported older version; newer or -unmigratable live tasks become orphaned. +`pending`, preserving their checkpoint and abort mark. Task migration runs at +reservation, atomically with `pending -> running`. One callback handles every +supported older version. A task whose definition is missing, older than the +stored version, or fails migration stays `pending` and blocked until a fitting +definition is registered or the task is aborted (section 5.4). `close()` marks the runtime closing, seals admission and reservation, signals invocations, and stops watches. Outside the Session line it settles admitted commits and joins invocations before closing storage. Already-running watch callbacks remain @@ -1412,7 +1425,8 @@ A terminal transition atomically: The execution checkpoint and memos disappear from the terminal representation. Terminal records remain queryable for dependencies, waiters, inspection, and reopen. A normal run becomes eligible when every `after` task is terminal. An abort -mark bypasses dependencies so pending work can always reach its abort handler. +mark bypasses dependencies so pending work can always reach its abort handler, +or its `orphaned` settlement when its definition is unavailable. ### 5.4 Scheduler, abort, and ownership @@ -1467,15 +1481,54 @@ owner edges are never retired with the task. Active invocations are signalled after commit, and a terminal receipt guarantees durable cancellation intent, not descendant quiescence. -Initial task definitions are registered before open performs live-task migration -and orphan reconciliation. Dynamic registration begins only after that pass. -Document migration remains access-driven. Unknown or unmigratable live task kinds -become terminal `orphaned`; -affected input submissions become unanswered, any matching active turn control is cleared, -task-scoped documents retire, and a visible notice entry is appended in one -commit. Faulting a turn task performs the same control/submission cleanup with a +Every run invocation start resolves the task's definition by `TaskRecord.kind` +from the scheduler's current registry snapshot: + +```text +definition missing -> stay pending, blocked: missing_task +stored version newer than it -> stay pending, blocked: task_too_old +stored version older than it -> migrate(input, checkpoint, storedVersion) + failure -> stay pending, blocked: migration_failed + success -> commit migrated record + running atomically +equal version -> commit running +``` + +An abort invocation resolves the definition the same way. When the definition +can take the task, its abort handler runs; otherwise the task is orphaned as +described below. + +The Harness never terminalizes a task merely because registry code is missing or +incompatible, neither at open nor later. A blocked task without an abort mark +keeps its durable record unchanged, remains live, still blocks ordinary idle +waits, and is reconsidered whenever the registry changes. The blocked reason is derived +runtime state, not a persisted task status. Document migration remains +access-driven. + +Aborting a blocked task cannot run its abort handler, because that code is +missing or cannot take the task. The Harness therefore settles it as terminal +`orphaned` instead of `aborted`: `aborted` means the task's own abort handler +ran and decided the outcome, while `orphaned` means no task code ran, so external +effects the task started may remain uncleaned. Only an abort (direct, by +conversation, or by cascade) orphans a task; a missing definition alone never +does. The orphaning commit performs the cleanup the task's code cannot: affected +input submissions become unanswered with the reason, any matching active turn +control is cleared, and task-scoped documents retire. No transcript entry is +written; the terminal task record and unanswered submissions carry the reason. +Faulting a turn task performs the same control/submission cleanup with a `faulted` outcome. +At every normal phase boundary (section 5.1, rule 5), after rereading the task +and applying the precedence rules, the scheduler refreshes the invocation's +registry snapshot. If the task definition resolved by name is a different object +than the one the invocation started with and the new definition can reserve the +task (same version, or a higher version with `migrate`), the invocation hands +over: it commits the task back to `pending` with its checkpoint, memos, and abort +mark, ends, and the next reservation starts a fresh invocation under the new +definition, applying the reservation rules above. When the definition is missing +or cannot reserve the task, the invocation keeps running under its old +definition, reports once, and reconsiders at its next boundary. A handler that +never settles never hands over. + ## 6. Submissions and inbox Submission records back awaitable host objects. Their record transitions use @@ -1541,18 +1594,193 @@ preserved. Chord's Astra operation generator must express scattered removals without carrying retained values; IDs are not substituted for positional inbox semantics. -## 7. Hooks, tools, and system sections +## 7. Registry, hooks, tools, and system prompt -### 7.1 Hooks +### 7.1 Registry + +Extension code reaches the Harness through one application-owned registry. The +registry is process-local, may outlive a Harness, and is the only place tools, +tool wrappers, hooks, tasks, and the system prompt are registered. Nothing +in it is persisted; durable state stays in conversations, entries, tasks, and +documents. + +```ts +type DocumentReader = Pick; + +interface Registration { + /** Idempotent; removes exactly the registrations this token covers. */ + dispose(): void; +} + +type ToolWrapper = (tool: Tool) => Tool; + +type HookScope = { + readonly conversationId: ConversationId; + /** Also match conversations owned, transitively, by tasks of this conversation. */ + readonly subtree?: boolean; +}; + +type PromptInput = { + readonly conversationId: ConversationId; + /** Active and registered tools in configured order, as offered in this request. */ + readonly tools: readonly Tool[]; + /** Sections already in effect after replaying the active transcript. */ + readonly shown: Readonly>; + readonly model?: ModelRef; + readonly thinkingLevel: ModelThinkingLevel; + /** Committed document reads. */ + readonly read: DocumentReader; +}; + +type PromptSection = { + readonly key: string; + render( + input: PromptInput, + context: Context, + ): string | undefined | Promise; + /** Default true: wrap the text as `\n...\n`. */ + readonly tag?: boolean; +}; + +type PromptSectionWrapper = ( + section: PromptSection, +) => PromptSection; + +interface RegistryReader { + /** Immutable view of the whole current registry. */ + snapshot(): RegistrySnapshot; + /** Called synchronously after every publication; wakes the scheduler to reconsider blocked tasks. */ + subscribe(listener: () => void): () => void; +} + +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[]; + task(name: string): AnyTask | undefined; + /** Hooks registered for tasks with `task`'s name, in registry order. */ + hooks(task: K): readonly { + readonly handlers: Partial>; + readonly scope?: HookScope; + }[]; + /** Composed sections in registry order; a section whose wrapper failed is absent. */ + sections(): readonly PromptSection[]; + /** Wrapper failures of this state; the Harness reports them where it uses them. */ + failures(): readonly { + readonly kind: "tool" | "section"; + readonly name: string; + readonly error: unknown; + }[]; +} + +interface Registry extends RegistryReader { + readonly tools: { + add(tool: Tool): Registration; + wrap(name: string, key: string, wrapper: ToolWrapper): Registration; + 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 systemPrompt: { + section( + key: string, + render: PromptSection["render"], + options?: { readonly tag?: boolean }, + ): Registration; + wrap(key: string, wrapperKey: string, wrapper: PromptSectionWrapper): Registration; + sections(): readonly PromptSection[]; + }; + batch(register: () => void): Registration; +} + +function createRegistry(): Registry; +``` + +The `Tool` parameter lets applications attach metadata such as prompt snippets to +their tools; the Harness only relies on `ToolRegistration`. Pi-ai declarations +derived from a registered tool keep only pi-ai `Tool` fields (`toToolDeclaration`), +so application metadata never enters the transcript. + +Registration rules: + +- Tool names, section keys, and task names are unique among published + registrations. Tool wrapper keys are unique per tool name, section wrapper + keys per section key, and hook keys per task name. Duplicates reject. + Section keys match `^[a-z][a-z0-9_-]*$`. +- Keyed slots are ordered by the position at which their key was first + registered. The registry remembers that position forever, so re-registering an + existing key, as a reload does, keeps its position; a new key appends. Hooks + without a key append. +- Registered objects are immutable and wrappers are pure: a wrapper returns a + new object and never mutates its input. +- Every call outside `batch()` publishes immediately. Publication is synchronous, + invokes no extension callback, and notifies subscribers. + +`batch(register)` stages every registration and disposal made while `register` +runs and publishes once when it returns. It validates only the final staged +state, so order inside the block does not matter: disposing an old tool and +adding its replacement is a gapless reload. If `register` throws, returns a +thenable, or the final state is invalid, nothing is published, staged disposals +are rolled back, and `batch()` throws. Only registrations made synchronously +inside `register` belong to the batch; a registration made later by a detached +continuation is an ordinary immediate registration. Calling `batch()` inside a +batch is a programming error and throws. The returned `Registration` covers every +registration added in the batch that was not disposed inside it. + +A snapshot is a plain immutable value; nothing is released. The scheduler takes +one per phase-handler invocation and passes it through the runtime; handlers and +hooks read that snapshot and never take their own. At every normal phase boundary +the scheduler takes a fresh one (section 5.4). A tool task keeps the composed tool +it pinned until execution settles. Different phases may observe different +registry states; nothing requires one turn to see a single registry state across +its generation, tool, and post-tools tasks. Host operations, such as the create +conveniences, take one snapshot inside their commit. + +```ts +// ToolTask is the built-in tool task's token; its hooks are listed in section 7.2. +const registration = registry.batch(() => { + registry.tools.add(grep); + registry.hooks.add(ToolTask, { beforeTool: audit }, { key: "audit" }); +}); +// later, reload: publish the replacement at once +registry.batch(() => { + registration.dispose(); + registry.tools.add(grepV2); + registry.hooks.add(ToolTask, { beforeTool: audit2 }, { key: "audit" }); +}); +``` + +The registry does not track which running work still uses a disposed +registration. Work that already started keeps using the code it took, so an +extension that frees resources right after `dispose()` can make a still-running +call fail with an ordinary error result. Extensions that need graceful disposal +manage their resources' lifetime themselves, for example by reference counting. + +### 7.2 Hooks A hook is a typed question asked by a task before it commits a decision. Hooks -are declared by task kind and registered in registration order Session-wide or -for a conversation and its owned subtree. They run off the line; a crash before -the consuming commit may rerun them. Abort errors always propagate. +are declared by task definition and registered through `registry.hooks.add()` +Session-wide or, with `scope`, for one conversation and optionally its owned +subtree. Subtree matching follows conversation ownership, not history parents. +Registration is typed by the task token, but dispatch matches the task's name, +so hooks survive a reload of their task; keeping hook signatures compatible +across task versions is the task author's responsibility. Hooks run in registry +order off the line; a crash before the consuming commit may rerun them. Abort +errors always propagate. | hook | composition | ordinary throw | |---|---|---| -| system instructions | all; draft changes compose; last tool override wins | roll back that handler, report, continue | | `beforeRequest` | replacement chain | report, continue | | `afterResponse` | all observers | report, continue | | `onYield` | first continuation wins | report, continue | @@ -1564,7 +1792,7 @@ the consuming commit may rerun them. Abort errors always propagate. Hooks use task memos for durable first-writer-wins decisions. There is no public semantic event channel; current UI status is document state. -### 7.2 Tools +### 7.3 Tools ```ts type ToolControl = { @@ -1587,13 +1815,13 @@ interface ConversationHandle { waitForIdle(context: Context): Promise; } -interface ToolExecutionApi extends DocumentObserver { +interface ToolExecutionApi extends DocumentObserver, DocumentReader { readonly taskId: TaskId; readonly conversationId: ConversationId; readonly callId: string; - stream(chunk: string | Uint8Array): void; + output(chunk: string | Uint8Array): void; + details(value: JsonValue, context: Context): Promise; commit(change: (tx: Tx) => T | Promise, context: Context): Promise; - progress(value: JsonObject, context: Context): Promise; memo(name: string, context: Context): Promise; memo(name: string, candidate: T, context: Context): Promise; createTask( @@ -1609,7 +1837,7 @@ interface ToolExecutionApi extends DocumentObserver { type ToolRegistration = Tool & { readonly replay?: "safe" | "unsafe"; - readonly output?: { + readonly outputLimits?: { readonly maxBytes?: number; readonly maxLines?: number; readonly retain?: "head" | "tail"; @@ -1622,34 +1850,59 @@ type ToolRegistration = Tool & { }; ``` -Omitted `replay` is `unsafe`. Omitted output bounds are 50 KiB, 2,000 lines, -and `retain: "head"`. `stream()` synchronously accepts UTF-8 output into that -invocation-owned bounded buffer and throws after invocation end. Throttled -commits publish the retained output and dropped byte/line counts in the tool -presentation document. If `execute()` omits `content`, the final retained stream -becomes one text content item; no stream becomes an empty content list. Explicit +Omitted `replay` is `unsafe`. Omitted `outputLimits` are 50 KiB, 2,000 lines, +and `retain: "head"`. + +A running tool reports two things to the UI, mirroring the two halves of its +final result: + +- `output(chunk)` appends running text output, like stdout. If `execute()` omits + `content`, the final retained output becomes one text content item; no output + becomes an empty content list. +- `details(value)` replaces the running details with a complete JSON value; it + does not merge keys. If `execute()` omits `details`, the last value becomes the + final `details`, so a renderer handles one details shape from the first + update through the final result. + +Neither is sent to the model while the tool runs. `output()` synchronously +accepts UTF-8 output into that invocation-owned bounded buffer and throws after +invocation end. Throttled commits publish the retained output, dropped +byte/line counts, and the current details in the tool presentation document. Explicit text in explicit result content is bounded by the same limits before transcript persistence; non-text content is retained as declared by its pi-ai type. -`stream()` never spills complete output to a file because spilling requires a +`output()` never spills complete output to a file because spilling requires a filesystem, which may be remote or unavailable. A tool that must preserve complete output spills through the `ExecutionEnv` or `FileSystem` it was given, such as shell execution with spill capture, and reports the resulting path in its -result details or progress. +details. -`progress(value)` replaces the invocation's complete JSON `progress` payload; it -does not merge keys. Its promise resolves after the corresponding or coalesced -document commit. During normal settlement, accepted output updates drain before +The `details()` promise resolves after the corresponding or coalesced document +commit. During normal settlement, accepted output updates drain before the tool-result entry and terminal task record commit. Abort and close obey invocation and Session admission gates: uncommitted buffered updates may be discarded, while admitted commits settle. Cancellation, callback, tracker preparation, and checkpoint failures occur before Storage admission and do not poison the Session. An uncertain Storage failure follows the fatal Session rule. -Tools are dynamically registered declarations with name, description, JSON -schema, replay policy, and execute function. A tool call is accepted only if it -was offered in the request's effective system/tool history. Arguments are -validated before and after `beforeTool` hooks. +Tools are registry entries (section 7.1) with name, description, JSON schema, +replay policy, and execute function. `registry.tools.wrap(name, key, wrapper)` +decorates a tool without replacing it. Each snapshot composes the current base +tool with its wrappers in registry order; a wrapper never captures a base, so +reloading the base keeps its wrappers. A wrapper that throws or returns a tool +with a different name makes that tool absent from the snapshot (fail closed: +not offered, and calls produce `tool_unavailable`) and is reported. A wrapper +without a base contributes nothing. The composite supplies the declaration, +argument validation, replay policy, and execution. + +A tool call is accepted only if it was offered in the request's effective +system/tool history. A call to a tool that is not offered or has no registered +implementation produces the `tool_unavailable` error result described in section +2.2 instead of failing the turn. The tool task resolves the composed tool once +from its snapshot before argument validation and pins it until execution +settles, even across later snapshot refreshes. Arguments must satisfy both the offered +declaration and the pinned implementation's schema; they are validated before +and after `beforeTool` hooks. After hooks and validation, the tool task durably records the final call and resolved replay policy before execution. Recovery does not rerun `beforeTool` @@ -1666,7 +1919,7 @@ ordinary transaction writes for passive entries. A tool executes in a durable task. It may: -- write bounded progress/output to a presentation or task-scoped document; +- publish bounded running output and details to its presentation document; - commit memos; - create and wait for tasks; - atomically create or fork explicitly owned conversations through `commit()`; @@ -1736,27 +1989,19 @@ terminate/handoff, and writes a headed handoff entry when requested. On reopen, a tool reruns only when both its stored intent policy and the current registered declaration say `safe`. A current `unsafe` declaration may veto a -stored-safe replay; a current-safe declaration never upgrades stored unsafe. -Every other orphaned effect produces an interrupted result containing the +stored-safe replay; a current-safe declaration never upgrades stored unsafe. A +tool with no current registration is treated as `unsafe`. Every other orphaned +effect produces an interrupted result containing the durable partial output. Completed, failed, and aborted tool terminal outcomes retain their tool-result entry ID for post-tools. -### 7.3 System sections and dynamic tools +### 7.4 System prompt and dynamic tools -```ts -interface SystemSection { - readonly key: string; - render(value: T): string; -} - -function defineSystemSection(definition: { - readonly key: string; - render(value: T): string; -}): SystemSection; -``` - -Pico stores prompt and tool changes directly as PR #9548 `SystemMessage` values -at their transcript positions: +Pico has no durable prompt sections. The registry's system prompt slot produces +the desired sections for each request; the transcript's `pi.system` entries are +the only durable record of what the model saw. Pico stores prompt and tool +changes directly as PR #9548 `SystemMessage` values at their transcript +positions, always with empty `content`: ```ts type SystemEntry = EntryRecord & { @@ -1766,8 +2011,8 @@ type SystemEntry = EntryRecord & { const baseline: SystemMessage = { role: "system", - content: basePrompt, - sections: { persona: renderedPersona, cwd: renderedCwd }, + content: "", + sections: { preamble: renderedPreamble, cwd: renderedCwd }, toolsAdded: allEffectiveTools, timestamp: now, }; @@ -1782,10 +2027,49 @@ const delta: SystemMessage = { }; ``` -System sections are registered by stable, non-integer-like key. Generation -prepares the desired rendered section values and effective tool roster, compares -them with the state obtained by replaying the active transcript, and appends a -positional `pi.system` baseline or delta. +Generation preparation takes these steps against its phase snapshot: + +1. Read the committed configuration and replay the active transcript's system + messages into the shown sections and offered tools. +2. Compute the desired tools: active names that the snapshot resolves, in + configured order, as composed by their wrappers. +3. Render every registered section, in registry order, with `PromptInput`: the + conversation, those tools, the shown sections, the configured model and + thinking level, and a `DocumentReader` for committed documents. The results + are the desired sections. +4. Compare desired sections and tool declarations with the replayed state and + append one positional `pi.system` entry when they differ. +5. The commit appending that entry first checks that the conversation's tail + entry, active head, and configuration are unchanged since step 1. If any + changed, preparation reruns from step 1 with the same registry snapshot. + +Model and thinking level are request options, not prompt state. + +Registered sections are the only source of prompt text; there is no separate +builder. `systemPrompt.section(key, render, { tag })` adds a section, and +`systemPrompt.wrap(key, wrapperKey, wrapper)` decorates one, composed per +snapshot like tool wrappers. Sections render in registry order: the position at +which each key was first registered. A section whose `render` returns +`undefined` is omitted, which is how a section varies by conversation. With +`tag` omitted or true, text is wrapped as `\n...\n`. A section that +throws keeps its shown text, if any, and is reported; the request is still sent. +Abort errors propagate. With no registered sections, the desired section set is +empty. + +A minimal prompt is one untagged section: + +```ts +registry.systemPrompt.section("preamble", () => "You are a helpful assistant.", { tag: false }); +``` + +Sections read per-conversation data through `input.read`. For example, a +coding agent keeps its own conversation document with the agent kind, preamble, +and working directory; its preamble and cwd sections render from that document, +and its AGENTS.md and skills sections return `undefined` for subagent +conversations. +Renderers must be deterministic for equal inputs: any change in rendered text, +such as an embedded timestamp, appends a system delta and invalidates provider +prompt caches. Replay applies messages in transcript order. Non-empty `content` appends instructions. A section string adds or replaces that name without moving an @@ -1793,74 +2077,60 @@ existing section; `null` removes it, and a later re-addition appends it to the ordered section map. Within one message, tool removals happen before additions, so a same-name replacement gets the new declaration and position. -The built-in configuration document stores sections as an ordered array, never -as an object whose key order must be inferred by Delta. `setSection(section, -undefined)` removes the record; setting it later appends it at the end. Preparation compares both values and order. If values can be patched -without changing order, it emits the minimal patch. If effective and desired -section order differ, one commit appends two `pi.system` entries: the first -removes every effective section with `null`, and the second re-adds every desired -section in desired order. This makes order-only changes and deletion/re-addition -between requests replay exactly; merely restating equal values is insufficient. +Preparation compares both values and order. If values can be patched without +changing order, it emits the minimal patch. If shown and desired section order +differ, one commit appends two `pi.system` entries: the first removes every +shown section with `null`, and the second re-adds every desired section in +desired order. This makes order-only changes and deletion/re-addition between +requests replay exactly; merely restating equal values is insufficient. A PR #9548 `SystemMessage` is always a patch, not a reset: it cannot remove previous `content` or restore section order merely by restating current values. Therefore, when a head removes the previous request-visible baseline, the new `pi.system` entry adds `ContextEdit` omissions for every earlier `pi.system` entry still retained after the cut. Its own message is then a complete baseline -containing the base `content`, every desired section in order, and every effective -tool declaration. Model-context replay sees the new baseline instead of the -omitted retained deltas. Head rebaselining takes precedence over ordinary -order/value patching. Without a head cut, an order mismatch uses the two-entry -remove/re-add sequence above; only when order already matches does preparation -emit the minimal changed values, `null` removals, and tool additions/removals. -Same-name tool replacements remove before adding. Registry or document changes -while preparation hooks run cause preparation to retry against a new snapshot. +containing every desired section in order and every effective tool declaration. +Model-context replay sees the new baseline instead of the omitted retained +deltas. Head rebaselining takes precedence over ordinary order/value patching. +Without a head cut, an order mismatch uses the two-entry remove/re-add sequence +above; only when order already matches does preparation emit the minimal changed +values, `null` removals, and tool additions/removals. A changed tool declaration +is removed and re-added in the same message. When the offered tool order differs +from the desired order, the message removes every offered tool and re-adds the +desired tools in order. -Conversation creation may seed section values or explicit removals. Preparation -uses a mutable section draft with get/set/delete/wrap; each throwing hook loses -only its own draft changes. The rendered strings stored in historical -`SystemMessage.sections` remain authoritative even if the current renderer -changes. Pi-ai decides whether to send the messages positionally to a capable -provider or fold them into one leading system message; Pico does not rewrite its -stored transcript for provider compatibility. +The rendered strings stored in historical `SystemMessage.sections` remain +authoritative even if the current renderer changes. Pi-ai decides whether to +send the messages positionally to a capable provider or fold them into one +leading system message; Pico does not rewrite its stored transcript for provider +compatibility. -### 7.4 Host extension reload +### 7.5 Extension reload -A host extension generation is the Session-side code implementing its task kinds, -hooks, tools, sections, and document definitions. Registry APIs may -change registered declarations during normal product operation, but they do not -make replacement of that implementation code safe while its callbacks are -running. Document definitions are passed explicitly to typed access rather than -registered. +Extension code is reloaded in process through the registry. The host publishes +the new registrations and disposes the old ones in one `batch()`. The Harness +keeps running; no close or reopen is required. -In v1, changing host extension code is a Harness generation boundary: +- New phase invocations and tool tasks use the new registrations immediately; + running invocations see them at their next phase boundary. +- Work already running keeps its snapshot until its phase handler settles, and a + pinned tool keeps its implementation until its execution settles. Replacement + never signals or interrupts it. Resources the old code uses are the + extension's responsibility (section 7.1). +- A task definition replaced by name hands over at its next normal phase boundary + (section 5.4). A task definition must increase its version when the meaning + of persisted input or checkpoint state changes and migrate supported older + state; a failed migration blocks the task rather than terminalizing it. +- Keyed registrations re-registered under the same key keep their position, so a + reload does not reorder hooks, tools, or sections. +- Document definitions are passed explicitly to typed access and need no + registration; changed tokens take effect on their next access. -1. Stop new admission and task reservation. -2. Close the Harness, signalling and joining its active task, tool, and hook - invocations without writing abort marks or terminal outcomes. Stop future - watch deliveries without joining already-running callbacks. -3. Dispose the old facets and registrations. -4. Construct a new Harness over the same storage and new document tokens. -5. Register the complete task definition set before open performs live-task - migration and orphan reconciliation. Ordinary documents migrate on later - typed access. -6. Resume scheduling from the durable checkpoints. - -A durable task does not need to become terminal before this restart; only its -current invocation must settle. A task definition must increase its version when -the meaning of persisted input or checkpoint state changes and migrate supported -older state. Uncommitted hook work may rerun under the new generation; committed -memos remain part of the live task. - -If old extension code ignores cancellation and never settles, graceful in-process -reload cannot complete. The host must terminate that isolated worker/process -before opening the Session under the new generation. Old and new generations -must never own the same Session concurrently. - -Generation-pinned registries that drain old callbacks while routing new work to -new code, plus explicit compatible task takeover, are possible future work. Safe -forced takeover of arbitrary non-cooperative JavaScript requires worker/process -isolation and is not a v1 promise. +If old extension code ignores cancellation and never settles, new work already +uses the replacement. Harness close still joins every invocation. Safe forced termination of arbitrary +non-cooperative JavaScript requires worker/process isolation; that host +terminates the process and reopens the Session from durable state. Old and new +Harness instances must never own the same Session concurrently. ## 8. Built-in tasks @@ -1868,10 +2138,10 @@ The initial implementation provides: | kind | responsibility | |---|---| -| generation | prepare system/loadout, request or poll model, retry, classify response | -| tool | validate, hook, execute, persist progress, append result | -| post-tools | wait for tools, apply controls, run boundary, continue generation | -| collapse | select a transcript range, summarize, append a headed summary | +| `pi.generation` | prepare system/loadout, request or poll model, retry, classify response | +| `pi.tool` | validate, hook, execute, persist output and details, append result | +| `pi.post-tools` | wait for tools, apply controls, run boundary, continue generation | +| `pi.collapse` | select a transcript range, summarize, append a headed summary | Generation uses `HarnessOptions.models` without a Pico-specific model adapter. It resolves `models.getModel(ref.provider, ref.modelId)`, builds a pi-ai `Context` @@ -2424,6 +2694,17 @@ These are contracts, not invitations to add defensive machinery: outcome. Never reference a task-scoped document retired by that same outcome. - **Raw transcript:** view entries are not model context. Rendering edits, display-only entries, and model filtering require the appropriate reducer. +- **Reserved `pi.` names:** task names, document kinds, and entry kinds starting + with `pi.` belong to built-ins by convention. Nothing enforces it; reusing one + collides with Harness behavior, such as `pi.system` entries being replayed as + system messages. +- **Early resource disposal:** disposing a `Registration` only stops new use. + Freeing resources immediately can fail calls that are still running. +- **Async batches:** `batch()` callbacks are synchronous. A callback that returns + a promise publishes nothing and throws. +- **Unstable prompt text:** a section renderer whose output changes without a + real content change, for example by embedding the time, appends system deltas + and defeats provider prompt caching. - **Fatal storage errors:** after an uncertain storage failure the Session is poisoned. Do not catch the error and continue using it. - **JSONL durability:** default JSONL ordering handles ordinary process crashes; @@ -2444,4 +2725,4 @@ Pico5 initially has no: - SQL translation of arbitrary Chord operations; - JSONL global compaction or automatic corruption repair; - compatibility layer for removed Pico prototypes; -- in-process hot replacement of Session-side extension implementations. +- forced termination of non-cooperative extension code inside one process. diff --git a/packages/durable/src/entries.ts b/packages/durable/src/entries.ts new file mode 100644 index 000000000..0d50f9e5a --- /dev/null +++ b/packages/durable/src/entries.ts @@ -0,0 +1,11 @@ +import type { Entry } from "./harness/types.ts"; +import type { EntryRecord } from "./types.ts"; + +/** Define a typed entry kind whose `is()` guard narrows by `EntryRecord.kind`. */ +export function defineEntry(kind: string): Entry { + if (typeof kind !== "string" || kind.length === 0) throw new TypeError("Entry kind must be a non-empty string"); + return { + kind, + is: (entry: EntryRecord | undefined): entry is E => entry !== undefined && entry.kind === kind, + }; +} diff --git a/packages/durable/src/harness/config.ts b/packages/durable/src/harness/config.ts new file mode 100644 index 000000000..cb64466cc --- /dev/null +++ b/packages/durable/src/harness/config.ts @@ -0,0 +1,21 @@ +import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { defineDoc } from "../documents.ts"; + +/** Durable per-conversation model, thinking level, and desired tool loadout. */ +export type ConversationConfigState = { + model?: { provider: string; modelId: string }; + thinkingLevel: ModelThinkingLevel; + /** Desired tool names in offered order; names may be unregistered in the current process. */ + activeTools: string[]; +}; + +/** Built-in configuration document; rewindable so forks start from the configuration at their fork entry. */ +export const ConversationConfig = defineDoc({ + 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/context.ts b/packages/durable/src/harness/context.ts new file mode 100644 index 000000000..2c7490bbf --- /dev/null +++ b/packages/durable/src/harness/context.ts @@ -0,0 +1,115 @@ +import type { Context } from "@earendil-works/chord"; +import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; +import type { ContextEdit, ConversationId, Cursor, EntryId, EntryRecord, Storage } from "../types.ts"; +import type { ContextView } from "./types.ts"; + +const SCAN_PAGE_SIZE = 256; +const EXCLUDED_STOP_REASONS: ReadonlySet = new Set(["aborted", "error", "deferred"]); +const MISSING_RESULT_TEXT = "Tool result unavailable: history ends before this call completed."; + +/** Head marker and newest visible entry that fix one committed context range. */ +export type ContextBounds = { + readonly head: (EntryRecord & { readonly head: EntryId }) | undefined; + readonly tail: EntryId; +}; + +/** + * Capture the bounds of the current context with two O(1) reads. Run this on the Session line; entries at or below + * the tail are immutable, so `deriveContext()` can then scan them off the line. + */ +export async function captureContextBounds( + storage: Storage, + conversationId: ConversationId, + context: Context, +): Promise { + const tail = (await storage.scanEntries({ conversationId }, 1, undefined, context)).items[0]; + if (tail === undefined) return undefined; + return { head: await storage.findLatestHeadMarker(conversationId, tail.id, context), tail: tail.id }; +} + +/** + * Derive the active transcript and model context of one conversation within captured bounds. + * + * H = newest visible head marker; the range runs from `H.head` (or transcript start) through the tail. Per target, + * the newest edit in the range wins. Context entries are H followed by the range's non-head entries. + */ +export async function deriveContext( + storage: Storage, + conversationId: ConversationId, + bounds: ContextBounds | undefined, + context: Context, +): Promise { + if (bounds === undefined) return { head: undefined, entries: [], messages: [] }; + const head = bounds.head; + const range: EntryRecord[] = []; + let cursor: Cursor | undefined; + do { + const page = await storage.scanEntries( + head === undefined + ? { conversationId, maxEntryId: bounds.tail } + : { conversationId, minEntryId: head.head, maxEntryId: bounds.tail }, + SCAN_PAGE_SIZE, + cursor, + context, + ); + range.push(...page.items); + cursor = page.next; + } while (cursor !== undefined); + range.reverse(); + + const edits = new Map(); + for (const entry of range) for (const edit of entry.edits ?? []) edits.set(edit.target, edit); + + const entries = head === undefined ? range : [head, ...range.filter((entry) => entry.head === undefined)]; + const messages: Message[] = []; + for (const entry of entries) { + const edit = edits.get(entry.id); + if (edit?.action === "omit") continue; + const contributed = edit?.action === "replace" ? edit.messages : (entry.model ?? []); + for (const message of contributed) { + if (message.role === "assistant" && EXCLUDED_STOP_REASONS.has(message.stopReason)) continue; + messages.push(message); + } + } + return { head, entries, messages: orderToolResults(messages) }; +} + +/** + * Place each assistant's tool results directly after it in call order. Results are taken from the messages before + * the next assistant; a missing result is synthesized and unmatched results are dropped. + */ +function orderToolResults(messages: readonly Message[]): Message[] { + const ordered: Message[] = []; + for (let index = 0; index < messages.length; index++) { + const message = messages[index]!; + if (message.role === "toolResult") continue; + ordered.push(message); + if (message.role !== "assistant") continue; + const calls = message.content.filter((content): content is ToolCall => content.type === "toolCall"); + if (calls.length === 0) continue; + const results = new Map(); + for (let next = index + 1; next < messages.length && messages[next]!.role !== "assistant"; next++) { + const candidate = messages[next]!; + if (candidate.role === "toolResult" && !results.has(candidate.toolCallId)) { + results.set(candidate.toolCallId, next); + } + } + for (const call of calls) { + const resultIndex = results.get(call.id); + ordered.push(resultIndex === undefined ? missingResult(call, message.timestamp) : messages[resultIndex]!); + } + } + return ordered; +} + +function missingResult(call: ToolCall, timestamp: number): ToolResultMessage { + return { + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content: [{ type: "text", text: MISSING_RESULT_TEXT }], + isError: true, + details: { reason: "missing_result" }, + timestamp, + }; +} diff --git a/packages/durable/src/harness/harness.ts b/packages/durable/src/harness/harness.ts new file mode 100644 index 000000000..c4ff62d49 --- /dev/null +++ b/packages/durable/src/harness/harness.ts @@ -0,0 +1,244 @@ +import type { Context, Draft } from "@earendil-works/chord"; +import type { ModelThinkingLevel } from "@earendil-works/pi-ai"; +import { SessionImpl } from "../session/session.ts"; +import type { + ConversationId, + ConversationOwnership, + ConversationRecord, + Cursor, + EntryId, + EntryQuery, + EntryRecord, + Page, + Storage, + Tx, +} from "../types.ts"; +import { ROOT_CONVERSATION_ID } from "../types.ts"; +import { ConversationConfig, type ConversationConfigState } from "./config.ts"; +import { captureContextBounds, deriveContext } from "./context.ts"; +import type { + ContextView, + Conversation, + ConversationCreateOptions, + ConversationInit, + HarnessOptions, + Harness as HarnessType, + ModelRef, + RegistryReader, + RegistrySnapshot, + ToolRegistration, +} from "./types.ts"; + +type CreateTarget = + | { readonly kind: "root" } + | { readonly kind: "independent"; readonly ownership: ConversationOwnership } + | { + readonly kind: "fork"; + readonly parentId: ConversationId; + readonly at: EntryId; + readonly ownership: ConversationOwnership; + }; + +/** Harness-private services used by Conversation handles. */ +type ConversationHost = { + readonly harness: HarnessImpl; + readonly storage: Storage; + readonly registry: RegistryReader; + create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise; +}; + +class ConversationImpl implements Conversation { + readonly id: ConversationId; + readonly #host: ConversationHost; + + constructor(id: ConversationId, host: ConversationHost) { + this.id = id; + this.#host = host; + } + + async getModel(context: Context): Promise { + return (await this.#config(context)).model; + } + + 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); + } + + commit(change: (tx: Tx) => T | Promise, context: Context): Promise { + return this.#host.harness.commitWith(change, context, this.id); + } + + async context(context: Context): Promise { + const storage = this.#host.storage; + const bounds = await this.#host.harness.readOnLine(() => captureContextBounds(storage, this.id, context)); + return deriveContext(storage, this.id, bounds, context); + } + + entries( + query: Omit, + limit: number, + cursor: Cursor | undefined, + context: Context, + ): Promise> { + const bounded: EntryQuery = { + conversationId: this.id, + ...(query.minEntryId === undefined ? {} : { minEntryId: query.minEntryId }), + ...(query.maxEntryId === undefined ? {} : { maxEntryId: query.maxEntryId }), + }; + return this.#host.harness.readOnLine(() => this.#host.storage.scanEntries(bounded, limit, cursor, context)); + } + + fork(at: EntryId, options: ConversationCreateOptions, context: Context): Promise { + return this.#host.create( + { kind: "fork", parentId: this.id, at, ownership: options.ownership }, + options.init, + 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 #host: ConversationHost; + #closed = false; + + constructor(storage: Storage, options: HarnessOptions) { + super(storage); + this.#storage = storage; + this.#registry = options.registry; + this.#host = { + harness: this, + storage, + registry: options.registry, + create: (target, init, context) => this.#create(target, init, context), + }; + } + + root(context: Context, options?: { readonly init?: ConversationInit }): Promise { + return this.#create({ kind: "root" }, options?.init, context); + } + + async conversation(id: ConversationId, context: Context): Promise { + this.#assertOpen(); + const record = await this.readOnLine(() => this.#storage.conversation(id, context)); + return record === undefined ? undefined : new ConversationImpl(record.id, this.#host); + } + + createConversation(options: ConversationCreateOptions, context: Context): Promise { + return this.#create({ kind: "independent", ownership: options.ownership }, options.init, context); + } + + override close(context: Context): Promise { + this.#closed = true; + return super.close(context); + } + + async #create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise { + this.#assertOpen(); + const id = + target.kind === "root" + ? await this.commitRoot(async (tx) => { + if ((await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) return ROOT_CONVERSATION_ID; + return this.#stageConfiguration(tx, await tx.createRootConversation(), false, init); + }, context) + : await this.commitWith(async (tx) => { + const record = + target.kind === "fork" + ? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership }) + : await tx.createConversation({ ownership: target.ownership }); + return this.#stageConfiguration(tx, record, target.kind === "fork", init); + }, context); + return new ConversationImpl(id, this.#host); + } + + /** + * Stage a new conversation's configuration and `init` writes. Independent conversations start with every + * registered tool active; forks keep the configuration copied from their fork entry. + */ + async #stageConfiguration( + tx: Tx, + record: ConversationRecord, + forked: boolean, + init: ConversationInit | undefined, + ): Promise { + const snapshot = this.#registry.snapshot(); + if (!forked) (await tx.doc(ConversationConfig, record.id)).activeTools = [...snapshot.toolNames()]; + if (init === undefined) return record.id; + const baseline = [...(await tx.doc(ConversationConfig, record.id)).activeTools]; + await init(tx, record.id); + // `init` writes are trusted like any raw write; only names it newly activates must be registered. + requireRegistered(snapshot, (await tx.doc(ConversationConfig, record.id)).activeTools, baseline); + return record.id; + } + + #assertOpen(): void { + if (this.#closed) throw new Error("Harness is closed"); + } +} + +/** 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; + +export const Harness = { + /** Open a Harness over storage. The registry may keep changing while the Harness runs. */ + async open( + storage: Storage, + options: HarnessOptions, + context: Context, + ): Promise { + context.abortSignal?.throwIfAborted(); + return new HarnessImpl(storage, options); + }, +}; diff --git a/packages/durable/src/harness/registry.ts b/packages/durable/src/harness/registry.ts new file mode 100644 index 000000000..991b0ce78 --- /dev/null +++ b/packages/durable/src/harness/registry.ts @@ -0,0 +1,335 @@ +import type { + AnyTask, + HookRegistration, + HookScope, + HooksOf, + PromptSection, + PromptSectionWrapper, + Registration, + Registry, + RegistryFailure, + RegistrySnapshot, + ToolRegistration, + ToolWrapper, +} from "./types.ts"; + +const SECTION_KEY = /^[a-z][a-z0-9_-]*$/; + +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: "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. */ +class RegistryState implements RegistrySnapshot { + readonly records: readonly RegistryRecord[]; + #composition: Composition | undefined; + + constructor(records: readonly RegistryRecord[]) { + this.records = records; + } + + tools(): readonly Tool[] { + return this.#composed().tools; + } + + tool(name: string): Tool | undefined { + return this.#composed().toolsByName.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; + } + + 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; + } + + 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; + } +} + +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(); + #nextPosition = 0; + + readonly tools: Registry["tools"]; + readonly hooks: Registry["hooks"]; + readonly tasks: Registry["tasks"]; + 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.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; + } + + 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); + }); + }, + }; + } + + #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) }; + } + + #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"; + } +} + +/** Create an empty application-owned registry. */ +export function createRegistry(): Registry { + return new RegistryImpl(); +} + +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"); + } +} diff --git a/packages/durable/src/harness/types.ts b/packages/durable/src/harness/types.ts new file mode 100644 index 000000000..a49b5402a --- /dev/null +++ b/packages/durable/src/harness/types.ts @@ -0,0 +1,320 @@ +import type { Context, JsonValue } from "@earendil-works/chord"; +import type { Message, Models, ModelThinkingLevel, Tool, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; +import type { + ConversationId, + ConversationOwnership, + Cursor, + DocumentObserver, + EntryDraft, + EntryId, + EntryQuery, + EntryRecord, + Page, + Session, + SubmissionId, + SubmissionRecord, + Task, + TaskId, + TaskOptions, + TaskRecord, + TaskState, + Tx, +} from "../types.ts"; + +/** Provider and model ID resolved through pi-ai `Models`. */ +export type ModelRef = { + readonly provider: string; + readonly modelId: string; +}; + +export type UserInput = UserMessage["content"]; + +/** Host submission: user input that may start a turn, or a passive entry write. */ +export type SubmissionDraft = { + readonly requestId?: string; +} & ( + | { + readonly type: "input"; + readonly content: UserInput; + readonly whenBusy?: "steer" | "followUp" | "reject"; + readonly entry?: never; + } + | { + readonly type: "write"; + readonly entry: EntryDraft; + readonly content?: never; + readonly whenBusy?: never; + } +); + +export type InputSubmissionDraft = Extract; + +export type SettledSubmissionRecord = SubmissionRecord & { + readonly status: "done" | "unanswered"; +}; + +/** Awaitable host object for one durably admitted submission. */ +export interface Submission { + readonly id: SubmissionId; + status(context: Context): Promise; + wait(context: Context): Promise; + abort(context: Context): Promise<"aborted" | "already_placed" | "settled">; +} + +export type SettledTask = TaskRecord & { + readonly state: Extract, { readonly status: "terminal" }>; +}; + +/** Erased executable task definition stored in the registry. */ +export type AnyTask = { + readonly definition: { + readonly name: string; + readonly version: number; + readonly initial: unknown; + readonly phases: Readonly>; + readonly abort: unknown; + readonly migrate?: unknown; + readonly hooks?: object; + }; +}; + +/** Hook handler map declared by a task definition. */ +export type HooksOf = K extends Task ? H : never; + +/** Committed document reads. */ +export type DocumentReader = Pick; + +/** Invocation-bound conversation operations available to tools. */ +export interface ConversationHandle { + readonly id: ConversationId; + submit(submission: InputSubmissionDraft, context: Context): Promise; + abort(context: Context): Promise; + waitForIdle(context: Context): Promise; +} + +/** Post-tools controls requested by a tool result. */ +export type ToolControl = { + readonly addTools?: readonly string[]; + readonly terminate?: true; + readonly handoff?: string; +}; + +export type ToolExecutionResult = { + readonly content?: ToolResultMessage["content"]; + readonly isError?: boolean; + readonly details?: JsonValue; + readonly control?: ToolControl; +}; + +/** Operations available to one tool invocation. */ +export interface ToolExecutionApi extends DocumentObserver, DocumentReader { + readonly taskId: TaskId; + readonly conversationId: ConversationId; + readonly callId: string; + /** Append running output; it becomes the result content when the result omits `content`. */ + output(chunk: string | Uint8Array): void; + /** Replace running details; the last value becomes the result details when the result omits `details`. */ + details(value: JsonValue, 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; + createTask( + task: Task, + input: I, + options: Omit, + context: Context, + ): Promise>; + getTask(id: TaskId, context: Context): Promise | undefined>; + waitForTask(id: TaskId, context: Context): Promise>; + conversation(id: ConversationId, context: Context): Promise; +} + +/** Executable tool registered in a registry. Only pi-ai `Tool` fields enter the transcript. */ +export type ToolRegistration = Tool & { + /** Whether an interrupted execution may rerun on recovery. Default `unsafe`. */ + readonly replay?: "safe" | "unsafe"; + 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; +}; + +/** Input to system prompt section rendering for one request preparation. */ +export type PromptInput = { + readonly conversationId: ConversationId; + /** Active and registered tools in configured order, as offered in this request. */ + readonly tools: readonly Tool[]; + /** 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 = { + 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; + +/** One registered hook handler map for task `K`. */ +export type HookRegistration = { + readonly handlers: Partial>; + readonly scope?: HookScope; +}; + +/** Wrapper composition failure found while building a snapshot. */ +export type RegistryFailure = { + readonly kind: "tool" | "section"; + /** Tool name or section key. */ + readonly name: string; + readonly error: unknown; +}; + +/** 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[]; + 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[]; +} + +/** Read side of a registry consumed by a Harness. */ +export interface RegistryReader { + /** Immutable view of the whole current registry. */ + snapshot(): RegistrySnapshot; +} + +/** Application-owned registry of tools, hooks, tasks, and the system prompt. */ +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 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; +} + +/** + * 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. + */ +export type ConversationInit = (tx: Tx, conversationId: ConversationId) => void | Promise; + +export type ConversationCreateOptions = { + readonly ownership: ConversationOwnership; + readonly init?: ConversationInit; +}; + +export type HarnessOptions = { + /** pi-ai model access used by generation. */ + readonly models: Models; + readonly registry: RegistryReader; + readonly now?: () => number; + /** Receives extension failures that do not fail the calling operation. Must not throw. */ + readonly onReport?: (error: unknown) => void; +}; + +/** Typed entry kind with a narrowing guard. */ +export interface Entry { + readonly kind: string; + is(entry: EntryRecord | undefined): entry is E; +} + +/** Raw active transcript and derived model context. */ +export type ContextView = { + /** Newest applicable head marker, if any. */ + readonly head: EntryRecord | undefined; + /** Raw active entries: the head marker followed by non-head entries from its head through the tail. */ + readonly entries: readonly EntryRecord[]; + /** Model context for the next provider request. */ + readonly messages: readonly Message[]; +}; + +/** Stateless handle for one conversation, bound to the Harness that returned it. Compare handles by `id`. */ +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; + + /** Session commit whose `tx.createTask()` defaults to this conversation. */ + commit(change: (tx: Tx) => T | Promise, context: Context): Promise; + context(context: Context): Promise; + /** Newest-first fork-aware history of this conversation. */ + entries( + query: Omit, + limit: number, + cursor: Cursor | undefined, + context: Context, + ): Promise>; + fork(at: EntryId, options: ConversationCreateOptions, context: Context): Promise; +} + +// TODO: decide how Harness exposes subscribeCommits() and subscribeClose(). Their listeners run on the Session line +// and must not throw or call Session APIs, and Harness close will also join task invocations. +/** Durable agent harness over one Session. */ +export interface Harness extends Session { + /** Return the reserved root conversation, creating it with `init` in one commit when absent. */ + root(context: Context, options?: { readonly init?: ConversationInit }): Promise; + // Conversation activity (active/idle notifications, quiescence) is specified with the task runtime and turn + // control in Packages 14 and 17. + conversation(id: ConversationId, context: Context): Promise; + createConversation(options: ConversationCreateOptions, context: Context): Promise; +} diff --git a/packages/durable/src/index.ts b/packages/durable/src/index.ts index d442a2a46..92924ba2e 100644 --- a/packages/durable/src/index.ts +++ b/packages/durable/src/index.ts @@ -1,5 +1,43 @@ export { defineDoc, defineDocFamily } from "./documents.ts"; +export { defineEntry } from "./entries.ts"; export { ReadAfterWrite, StorageRejected } from "./errors.ts"; +export { ConversationConfig, type ConversationConfigState } from "./harness/config.ts"; +export { Harness } from "./harness/harness.ts"; +export { createRegistry } from "./harness/registry.ts"; +export type { + AnyTask, + ContextView, + Conversation, + ConversationCreateOptions, + ConversationHandle, + ConversationInit, + DocumentReader, + Entry, + HarnessOptions, + HookRegistration, + HookScope, + HooksOf, + InputSubmissionDraft, + ModelRef, + PromptInput, + PromptSection, + PromptSectionWrapper, + Registration, + Registry, + RegistryFailure, + RegistryReader, + RegistrySnapshot, + SettledSubmissionRecord, + SettledTask, + Submission, + SubmissionDraft, + ToolControl, + ToolExecutionApi, + ToolExecutionResult, + ToolRegistration, + ToolWrapper, + UserInput, +} from "./harness/types.ts"; export { createSession } from "./session/session.ts"; export { MemoryStorage } from "./storage/memory.ts"; export type { diff --git a/packages/durable/src/session/session.ts b/packages/durable/src/session/session.ts index a728dca4f..57a65183d 100644 --- a/packages/durable/src/session/session.ts +++ b/packages/durable/src/session/session.ts @@ -1,6 +1,6 @@ import { type Context, type JsonValue, replicatedState } from "@earendil-works/chord"; import { awaitWithContext, withoutAbortSignal } from "@earendil-works/chord/context"; -import { track } from "@earendil-works/chord/delta"; +import { type Op, track } from "@earendil-works/chord/delta"; import { type AnyDocToken, checkRecordScope, @@ -40,7 +40,7 @@ import { type LoadedDocument, Transaction, type TransactionHost } from "./transa /** Open a Session kernel over one storage backend. */ export function createSession(storage: Storage): Session { - return new SessionKernel(storage); + return new SessionImpl(storage); } /** @@ -49,7 +49,7 @@ export function createSession(storage: Storage): Session { * Only committed state is observable. Every commit callback, preparation, Storage settlement, adoption, and * publication enqueue runs while the line is held; listeners run later. */ -export class SessionKernel implements Session { +export class SessionImpl implements Session { readonly #storage: Storage; readonly #documents = new Map(); readonly #commitListeners = new Set<(publication: CommitPublication, context: Context) => void>(); @@ -83,18 +83,41 @@ export class SessionKernel implements Session { return this.#enqueue(() => this.#runCommit(change, context)); } - /** Internal conversation-bound commit used by the public Conversation handle. */ - commitForConversation( - conversationId: ConversationId, + /** Internal commit whose `tx.createTask()` defaults to `defaultConversationId`; used by Conversation handles. */ + commitWith( change: (tx: Tx) => T | Promise, context: Context, + defaultConversationId?: ConversationId, ): Promise { try { this.#assertUsable(); } catch (error) { return Promise.reject(error); } - return this.#enqueue(() => this.#runCommit(change, context, conversationId)); + return this.#enqueue(() => this.#runCommit(change, context, defaultConversationId)); + } + + /** Internal commit exposing the concrete transaction, whose reserved-ID root bootstrap is not on the public `Tx`. */ + commitRoot(change: (tx: Transaction) => T | Promise, context: Context): Promise { + try { + this.#assertUsable(); + } catch (error) { + return Promise.reject(error); + } + return this.#enqueue(() => this.#runCommit(change, context)); + } + + /** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */ + readOnLine(job: () => Promise): Promise { + try { + this.#assertUsable(); + } catch (error) { + return Promise.reject(error); + } + return this.#enqueue(async () => { + this.#assertHealthy(); + return job(); + }); } snapshot(token: SessionDocToken, context: Context): Promise | undefined>; @@ -130,12 +153,14 @@ export class SessionKernel implements Session { const definition = token.definition; const resolved = resolveAddress(definition, args); const context = args[resolved.nextArgument] as Context; + const cached = this.#documents.get(resolved.id); const loaded = - this.#documents.get(resolved.id) ?? - (await this.#enqueue(async () => { - this.#assertHealthy(); - return this.#loadDocument(definition, resolved.id, resolved.address, context); - })); + cached?.valueVersion === definition.version + ? cached + : await this.#enqueue(async () => { + this.#assertHealthy(); + return this.#loadDocument(definition, resolved.id, resolved.address, context); + }); if (loaded === undefined) return undefined; checkRecordScope(definition, loaded.record); checkRecordVersion(definition, loaded.record, loaded.storedVersion); @@ -191,14 +216,11 @@ export class SessionKernel implements Session { unsubscribeCommit(); unsubscribeClose(); }); + const observed = { version: loaded.valueVersion }; unsubscribeCommit = this.subscribeCommits((publication, commitContext) => { for (const change of publication.changes) { if (change.type !== "document" || change.record.id !== loaded.record.id) continue; - source.advance( - change.value, - change.value === null ? RETIREMENT_OPERATIONS : change.ops, - withoutAbortSignal(commitContext), - ); + source.advance(change.value, observedOperations(observed, change), withoutAbortSignal(commitContext)); } }); unsubscribeClose = this.subscribeClose(() => source.closeSession()); @@ -269,14 +291,11 @@ export class SessionKernel implements Session { unsubscribeCommit(); unsubscribeClose(); }); + const observed = { version: loaded.valueVersion }; unsubscribeCommit = this.subscribeCommits((publication, commitContext) => { for (const change of publication.changes) { if (change.type !== "document" || change.record.id !== loaded.record.id) continue; - watch.advance( - change.value, - change.value === null ? RETIREMENT_OPERATIONS : change.ops, - commitContext, - ); + watch.advance(change.value, observedOperations(observed, change), commitContext); } }); unsubscribeClose = this.subscribeClose(() => watch.closeSession()); @@ -377,7 +396,7 @@ export class SessionKernel implements Session { } async #runCommit( - change: (tx: Tx) => T | Promise, + change: (tx: Transaction) => T | Promise, context: Context, defaultConversationId?: ConversationId, ): Promise { @@ -447,7 +466,9 @@ export class SessionKernel implements Session { context: Context, ): Promise { const cached = this.#documents.get(addressId); - if (cached !== undefined) return cached; + // A tracker serves only tokens of the version its value was materialized for; others reload from Storage. + if (cached?.valueVersion === definition.version) return cached; + if (cached !== undefined) this.#documents.delete(addressId); const record = await this.#storage.findDocument(address, "current", context); if (record === undefined) return undefined; const stored = await this.#storage.document(record.id, "current", context); @@ -457,6 +478,7 @@ export class SessionKernel implements Session { addressId, record: stored.record, storedVersion: stored.version, + valueVersion: definition.version, deltasSinceBase: stored.deltasSinceBase, tracker: track(value), }; @@ -487,6 +509,20 @@ export class SessionKernel implements Session { } } +/** + * Operations an observer applies for one committed change. An observer hydrated under another definition version holds + * a differently shaped value, so it receives the new value as a root replacement instead of operations for that shape. + */ +function observedOperations( + observed: { version: number }, + change: Extract, +): readonly Op[] { + if (change.value === null) return RETIREMENT_OPERATIONS; + if (change.version === observed.version) return change.ops; + observed.version = change.version!; + return [["r", change.value]]; +} + function cancellationError(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } diff --git a/packages/durable/src/session/transaction.ts b/packages/durable/src/session/transaction.ts index 841d0b7b1..258f8f623 100644 --- a/packages/durable/src/session/transaction.ts +++ b/packages/durable/src/session/transaction.ts @@ -60,6 +60,8 @@ export type LoadedDocument = { readonly record: DocumentRecord; /** Persisted definition version; older while the tracked value is migrated only in memory. */ storedVersion: number; + /** Definition version whose shape the tracked value has; access with another version reloads from Storage. */ + readonly valueVersion: number; /** Stored deltas after the newest base; advanced by adoption so the next predicate call needs no read. */ deltasSinceBase: number; readonly tracker: Tracker; @@ -550,6 +552,7 @@ export class Transaction implements Tx { addressId: document.addressId, record, storedVersion: target.version, + valueVersion: target.version, deltasSinceBase: 0, tracker: target.tracker, }); diff --git a/packages/durable/test/harness-context.test.ts b/packages/durable/test/harness-context.test.ts new file mode 100644 index 000000000..3a871a612 --- /dev/null +++ b/packages/durable/test/harness-context.test.ts @@ -0,0 +1,135 @@ +import type { Message } from "@earendil-works/pi-ai"; +import { type EntryDraft, type EntryId, type EntryRecord, MemoryStorage } from "@earendil-works/pi-durable"; +import { describe, expect, it } from "vitest"; +import { assistant, describeMessage, openHarness, system, toolResult, user } from "./harness-support.ts"; +import { context } from "./session-support.ts"; + +async function setup() { + const { harness } = await openHarness(new MemoryStorage()); + const root = await harness.root(context); + const append = (draft: EntryDraft): Promise => + root.commit((tx) => tx.appendEntry(root.id, draft), context); + const message = (model: Message, kind = "message"): Promise => append({ kind, model: [model] }); + return { harness, root, append, message }; +} + +function ids(entries: readonly EntryRecord[]): EntryId[] { + return entries.map((entry) => entry.id); +} + +describe("conversation context", () => { + it("returns the whole transcript without a head and excludes model-less entries from messages", async () => { + const { root, append, message } = await setup(); + const first = await message(user("hi")); + const note = await append({ kind: "note", data: { text: "display only" } }); + const answer = await message(assistant("hello")); + const view = await root.context(context); + expect(view.head).toBeUndefined(); + expect(ids(view.entries)).toEqual([first.id, note.id, answer.id]); + expect(view.messages.map(describeMessage)).toEqual(["user:hi", "assistant:hello"]); + }); + + it("excludes aborted, error, and deferred assistant messages but keeps their raw entries", async () => { + const { root, message } = await setup(); + await message(user("q")); + const aborted = await message(assistant("partial", { stopReason: "aborted" })); + await message(assistant("failed", { stopReason: "error" })); + await message(assistant("later", { stopReason: "deferred" })); + await message(assistant("done", { stopReason: "length" })); + const view = await root.context(context); + expect(view.entries).toHaveLength(5); + expect(view.entries[1]!.id).toBe(aborted.id); + expect(view.messages.map(describeMessage)).toEqual(["user:q", "assistant:done"]); + }); + + it("resolves self heads and uses the newest head marker", async () => { + const { root, append, message } = await setup(); + await message(user("old")); + const reset = await append({ kind: "reset", head: "self", model: [user("fresh start")] }); + expect(reset.head).toBe(reset.id); + const after = await message(assistant("after reset")); + let view = await root.context(context); + expect(view.head?.id).toBe(reset.id); + expect(ids(view.entries)).toEqual([reset.id, after.id]); + expect(view.messages.map(describeMessage)).toEqual(["user:fresh start", "assistant:after reset"]); + + // A collapse summary heads an earlier kept entry; older head markers in range drop out. + const summary = await append({ kind: "summary", head: after.id, model: [user("summary")] }); + const tail = await message(user("next")); + view = await root.context(context); + expect(view.head?.id).toBe(summary.id); + expect(ids(view.entries)).toEqual([summary.id, after.id, tail.id]); + expect(view.messages.map(describeMessage)).toEqual(["user:summary", "assistant:after reset", "user:next"]); + }); + + it("applies the newest edit per target within the active range", async () => { + const { root, append, message } = await setup(); + const first = await message(user("first")); + const second = await message(user("second")); + await append({ kind: "edit", edits: [{ target: first.id, action: "replace", messages: [user("first v2")] }] }); + await append({ kind: "edit", edits: [{ target: first.id, action: "replace", messages: [user("first v3")] }] }); + await append({ kind: "edit", edits: [{ target: second.id, action: "omit" }] }); + let view = await root.context(context); + expect(view.entries).toHaveLength(5); + expect(view.messages.map(describeMessage)).toEqual(["user:first v3"]); + + // Edits before the active range no longer apply. + const reset = await append({ kind: "reset", head: second.id }); + view = await root.context(context); + expect(view.head?.id).toBe(reset.id); + expect(view.messages.map(describeMessage)).toEqual([]); + await append({ kind: "edit", edits: [{ target: second.id, action: "replace", messages: [user("second v2")] }] }); + view = await root.context(context); + expect(view.messages.map(describeMessage)).toEqual(["user:second v2"]); + }); + + it("keeps positional system messages and orders tool results by call order", async () => { + const { root, message, append } = await setup(); + await message(system({ preamble: "You help." }), "pi.system"); + await message(user("run tools")); + await message(assistant("calling", { calls: ["b", "a"] })); + await message(toolResult("a")); + await append({ kind: "pi.system", model: [system({ cwd: "/repo" })] }); + await message(toolResult("b")); + await message(toolResult("zz")); + await message(assistant("done")); + const view = await root.context(context); + expect(view.messages.map(describeMessage)).toEqual([ + "system:preamble", + "user:run tools", + "assistant:calling", + "result:b:result b", + "result:a:result a", + "system:cwd", + "assistant:done", + ]); + }); + + it("synthesizes missing tool results after a fork and drops results cut from their call", async () => { + const { root, message } = await setup(); + await message(user("go")); + const call = await message(assistant("calling", { calls: ["x", "y"] })); + await message(toolResult("x")); + const second = await message(toolResult("y")); + const child = await root.fork(call.id, { ownership: { kind: "ownerless" } }, context); + const childView = await child.context(context); + expect(childView.messages.map(describeMessage)).toEqual([ + "user:go", + "assistant:calling", + "result:x:error", + "result:y:error", + ]); + const missing = childView.messages[2]!; + expect(missing).toMatchObject({ + role: "toolResult", + toolName: "tool-x", + details: { reason: "missing_result" }, + }); + + // A head between a call and its results leaves stray results that are not sent. + await root.commit((tx) => tx.appendEntry(root.id, { kind: "reset", head: second.id }), context); + const parentView = await root.context(context); + expect(parentView.messages.map(describeMessage)).toEqual([]); + expect(parentView.entries.map((entry) => entry.kind)).toEqual(["reset", "message"]); + }); +}); diff --git a/packages/durable/test/harness-conversations.test.ts b/packages/durable/test/harness-conversations.test.ts new file mode 100644 index 000000000..4b1859570 --- /dev/null +++ b/packages/durable/test/harness-conversations.test.ts @@ -0,0 +1,392 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type Conversation, + ConversationConfig, + type ConversationId, + defineDoc, + defineEntry, + type EntryRecord, + MemoryStorage, + ROOT_CONVERSATION_ID, + type Task, +} 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 { ControlledStorage, context } from "./session-support.ts"; + +const directories = new Set(); + +async function sqlitePath(): Promise { + const directory = await mkdtemp(join(tmpdir(), "pi-durable-harness-")); + directories.add(directory); + return join(directory, "session.sqlite"); +} + +afterEach(async () => { + for (const directory of directories) await rm(directory, { recursive: true, force: true }); + directories.clear(); +}); + +const NoteDoc = defineDoc<{ text: string }>({ + kind: "test.note", + version: 1, + scope: "conversation", + history: "rewindable", + fork: "asOf", + initial: () => ({ text: "" }), +}); + +const Message = defineEntry("message"); + +async function append(conversation: Conversation, text: string): Promise { + return conversation.commit( + (tx) => tx.appendEntry(conversation.id, { kind: "message", model: [user(text)] }), + context, + ); +} + +async function allEntries(conversation: Conversation): Promise { + const texts: string[] = []; + let cursor: Parameters[2]; + do { + const page = await conversation.entries({}, 2, cursor, context); + for (const entry of page.items) texts.push((entry.model?.[0] as { content: string }).content); + cursor = page.next; + } while (cursor !== undefined); + return texts; +} + +describe("Harness root and conversations", () => { + it("creates the root lazily with its configuration 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, { + init: async (tx, id) => { + (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); + expect(storage.commits[0]!.map((write) => write.type)).toEqual([ + "conversation", + "document.create", + "document.create", + ]); + expect(await root.getActiveTools(context)).toEqual(["read", "bash"]); + expect(await root.getThinkingLevel(context)).toBe("high"); + expect(await harness.snapshot(NoteDoc, root.id, context)).toEqual({ text: "root note" }); + + expect((await harness.root(context, { init: () => expect.unreachable() })).id).toBe(root.id); + expect(storage.commits).toHaveLength(1); + await harness.close(context); + }); + + it("keeps root and conversation identity and state across reopen", async () => { + 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); + 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(); + + 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 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 first.harness.conversation(999 as ConversationId, context)).toBeUndefined(); + await first.harness.close(context); + }); + + it("creates independent conversations atomically with init and rolls back failures", async () => { + const storage = new ControlledStorage(); + const { harness, registry } = await openHarness(storage, ["read"]); + const created = await harness.createConversation( + { + ownership: { kind: "ownerless" }, + init: async (tx, id) => { + await tx.appendEntry(id, { kind: "message", model: [user("seed")] }); + }, + }, + context, + ); + 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" }, + init: () => { + throw new Error("init failed"); + }, + }, + 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"]); + await harness.close(context); + }); + + it("forks at a concrete entry with as-of configuration and init overrides", async () => { + const { harness, registry } = await openHarness(new MemoryStorage(), ["read"]); + const legacy = registry.tools.add(tool("legacy")); + const root = await harness.root(context); + await root.setThinkingLevel("low", context); + const at = await append(root, "one"); + await root.setThinkingLevel("high", context); + await root.setActiveTools(["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 allEntries(child)).toEqual(["one"]); + + const overridden = await root.fork( + at.id, + { + ownership: { kind: "ownerless" }, + init: async (tx, id) => { + const config = await tx.doc(ConversationConfig, id); + config.thinkingLevel = "minimal"; + config.activeTools = ["legacy"]; + }, + }, + context, + ); + expect(await overridden.getThinkingLevel(context)).toBe("minimal"); + expect(await overridden.getActiveTools(context)).toEqual(["legacy"]); + expect(await root.getThinkingLevel(context)).toBe("high"); + + const unrelated = await harness.createConversation({ ownership: { kind: "ownerless" } }, context); + await expect(root.fork(at.id, { ownership: { kind: "ownerless" } }, context)).resolves.toBeDefined(); + await expect(unrelated.fork(at.id, { ownership: { kind: "ownerless" } }, context)).rejects.toThrow( + "is not visible", + ); + 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); + await append(root, "r1"); + const [r2] = await root.commit( + async (tx) => [ + await tx.appendEntry(root.id, { kind: "message", model: [user("r2")] }), + await tx.appendEntry(root.id, { kind: "message", model: [user("r3")] }), + ], + context, + ); + const child = await root.fork(r2!.id, { ownership: { kind: "ownerless" } }, context); + const c1 = await append(child, "c1"); + await append(child, "c2"); + const grandchild = await child.fork(c1.id, { ownership: { kind: "ownerless" } }, context); + await append(grandchild, "g1"); + + expect(await allEntries(root)).toEqual(["r3", "r2", "r1"]); + expect(await allEntries(child)).toEqual(["c2", "c1", "r2", "r1"]); + expect(await allEntries(grandchild)).toEqual(["g1", "c1", "r2", "r1"]); + const bounded = await grandchild.entries( + { minEntryId: r2!.id, maxEntryId: c1.id, conversationId: root.id } as never, + 10, + undefined, + context, + ); + expect(bounded.items.map((entry) => entry.id)).toEqual([c1.id, r2!.id]); + expect(Message.is(bounded.items[0])).toBe(true); + expect(Message.is(undefined)).toBe(false); + await harness.close(context); + }); + + it("binds commits and task creation to the conversation", async () => { + const { harness } = await openHarness(new MemoryStorage()); + const conversation = await harness.createConversation({ ownership: { kind: "ownerless" } }, context); + const task: Task<{ n: number }, { phase: "run" }, void, object> = { + definition: { name: "test.work", version: 1, initial: () => ({ phase: "run" }) }, + }; + const taskId = await conversation.commit((tx) => tx.createTask(task, { n: 1 }), context); + const record = await harness.commit((tx) => tx.task(taskId), context); + expect(record?.conversationId).toBe(conversation.id); + await expect(harness.commit((tx) => tx.createTask(task, { n: 2 }), context)).rejects.toThrow( + "requires options.conversationId", + ); + 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"]); + const root = await harness.root(context); + expect(await root.getModel(context)).toBeUndefined(); + expect(await root.getThinkingLevel(context)).toBe("off"); + + 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.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 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("reads initial configuration for conversations created without one", async () => { + const storage = new ControlledStorage(); + const { harness } = await openHarness(storage, ["read"]); + const id = await harness.commit( + async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, + context, + ); + const raw = await harness.conversation(id, context); + 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(storage.commits).toHaveLength(commits); + await raw!.setActiveTools(["read"], context); + expect(await raw!.getActiveTools(context)).toEqual(["read"]); + await harness.close(context); + }); +}); + +describe("Harness lifecycle", () => { + it("returns stateless handles and rejects operations after close", async () => { + const { harness } = await openHarness(new MemoryStorage()); + const root = await harness.root(context); + const again = await harness.root(context); + expect(again).not.toBe(root); + expect(again.id).toBe(root.id); + await harness.close(context); + await expect(harness.root(context)).rejects.toThrow("Harness is closed"); + await expect(harness.createConversation({ ownership: { kind: "ownerless" } }, context)).rejects.toThrow( + "Harness is closed", + ); + await expect(harness.conversation(root.id, context)).rejects.toThrow("Harness is closed"); + }); + + it("forwards generic Session document APIs", async () => { + const { harness } = await openHarness(new MemoryStorage()); + const root = await harness.root(context); + const entry = await root.commit(async (tx) => { + (await tx.doc(NoteDoc, root.id)).text = "first"; + return tx.appendEntry(root.id, { kind: "message", model: [user("m")] }); + }, context); + await harness.commit(async (tx) => { + (await tx.doc(NoteDoc, root.id)).text = "second"; + }, context); + expect(await harness.snapshot(NoteDoc, root.id, context)).toEqual({ text: "second" }); + expect(await harness.snapshotAsOf(NoteDoc, root.id, entry.id, context)).toEqual({ text: "first" }); + const state = await harness.documentState(NoteDoc, root.id, context); + expect(state?.value).toEqual({ text: "second" }); + state?.dispose(); + await harness.close(context); + }); +}); diff --git a/packages/durable/test/harness-registry.test.ts b/packages/durable/test/harness-registry.test.ts new file mode 100644 index 000000000..e824a82b2 --- /dev/null +++ b/packages/durable/test/harness-registry.test.ts @@ -0,0 +1,233 @@ +import type { JsonValue } from "@earendil-works/chord"; +import { Type } from "@earendil-works/pi-ai"; +import { type AnyTask, createRegistry, type ToolRegistration } from "@earendil-works/pi-durable"; +import { describe, expect, it } from "vitest"; + +type AppTool = ToolRegistration & { readonly snippet?: string }; + +function tool(name: string, extra: Partial = {}): AppTool { + return { + name, + description: `${name} tool`, + parameters: Type.Object({}), + execute: async () => ({ content: [] }), + ...extra, + }; +} + +function task(name: string) { + const hooks: { beforeRun(): void } = { beforeRun: () => {} }; + return { + definition: { + name, + version: 1, + initial: (_input: undefined) => ({ phase: "run" as const }), + phases: {}, + abort: () => {}, + hooks, + }, + } satisfies AnyTask; +} + +function names(tools: readonly { readonly name: string }[]): string[] { + return tools.map((entry) => entry.name); +} + +describe("registry", () => { + it("adds, lists, and disposes tools by exact token", () => { + 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 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([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("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); + }); +}); diff --git a/packages/durable/test/harness-support.ts b/packages/durable/test/harness-support.ts new file mode 100644 index 000000000..b661a1773 --- /dev/null +++ b/packages/durable/test/harness-support.ts @@ -0,0 +1,104 @@ +import type { + AssistantMessage, + Message, + StopReason, + SystemMessage, + ToolCall, + ToolResultMessage, + UserMessage, +} from "@earendil-works/pi-ai"; +import { createModels, Type } from "@earendil-works/pi-ai"; +import { + createRegistry, + Harness, + type Registry, + type Storage, + 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: [] }) }; +} + +/** Open a Harness with a fresh registry holding the named tools. */ +export async function openHarness( + storage: Storage, + toolNames: readonly string[] = [], + 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)); + const harness = await Harness.open( + storage, + { models: createModels(), registry, ...(options.onReport === undefined ? {} : { onReport: options.onReport }) }, + context, + ); + return { harness, registry }; +} + +export function user(text: string): UserMessage { + return { role: "user", content: text, timestamp: 1 }; +} + +export function assistant( + text: string, + options: { readonly calls?: readonly string[]; readonly stopReason?: StopReason } = {}, +): AssistantMessage { + const calls: ToolCall[] = (options.calls ?? []).map((id) => ({ + type: "toolCall", + id, + name: `tool-${id}`, + arguments: {}, + })); + return { + role: "assistant", + content: [{ type: "text", text }, ...calls], + api: "faux", + provider: "faux", + model: "faux", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: options.stopReason ?? (calls.length > 0 ? "toolUse" : "stop"), + timestamp: 2, + }; +} + +export function toolResult(id: string, text = `result ${id}`): ToolResultMessage { + return { + role: "toolResult", + toolCallId: id, + toolName: `tool-${id}`, + content: [{ type: "text", text }], + isError: false, + timestamp: 3, + }; +} + +export function system(sections: Record): SystemMessage { + return { role: "system", content: "", sections, timestamp: 4 }; +} + +/** Compact message rendering for assertions. */ +export function describeMessage(message: Message): string { + switch (message.role) { + case "user": + return `user:${message.content as string}`; + case "assistant": { + const text = message.content.find((content) => content.type === "text"); + return `assistant:${text?.type === "text" ? text.text : ""}`; + } + case "toolResult": { + const text = message.content.find((content) => content.type === "text"); + return `result:${message.toolCallId}:${message.isError ? "error" : text?.type === "text" ? text.text : ""}`; + } + case "system": + return `system:${Object.keys(message.sections ?? {}).join(",")}`; + } +} diff --git a/packages/durable/test/scratch.ts b/packages/durable/test/scratch.ts index 138f952b6..1992b8528 100644 --- a/packages/durable/test/scratch.ts +++ b/packages/durable/test/scratch.ts @@ -1,8 +1,29 @@ -// A tour of the durable Session API in six small examples. +// A tour of the durable Session and Harness APIs in twelve small examples. // Run from packages/durable: // node --conditions=source --experimental-strip-types test/scratch.ts import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { type ConversationId, createSession, defineDoc, MemoryStorage, type Task } from "../src/index.ts"; +import { + type AssistantMessage, + createModels, + type Message, + type StopReason, + type ToolResultMessage, + Type, +} from "@earendil-works/pi-ai"; +import { + ConversationConfig, + type ConversationId, + createRegistry, + createSession, + defineDoc, + defineEntry, + type EntryRecord, + Harness, + MemoryStorage, + type PromptInput, + type Task, + type ToolRegistration, +} from "../src/index.ts"; // A Session stores conversations, transcript entries, tasks, and documents. // MemoryStorage keeps everything in memory; other storage backends keep it on disk. @@ -170,3 +191,291 @@ await delivered; await notesWatch.stop(); await session.close(context); + +// ─── 7. Open a Harness with a registry ────────────────────────────────────── +// A Harness is a Session plus conversation handles. Extension code (tools, +// hooks, tasks, system prompt sections) lives in a registry the +// application owns. Nothing in the registry is saved; it is this process's code. +// Apps may attach their own metadata to tools, such as a prompt snippet. +type AppTool = ToolRegistration & { readonly snippet?: string }; + +function exampleTool(name: string, description: string): AppTool { + return { + name, + description, + parameters: Type.Object({ path: Type.String() }), + snippet: `Use ${name} for files.`, + execute: async (args) => ({ content: [{ type: "text", text: `${name} ${JSON.stringify(args)}` }] }), + }; +} + +const registry = createRegistry(); +registry.tools.add(exampleTool("read", "Read a file")); +const writeRegistration = registry.tools.add(exampleTool("write", "Write a file")); +const grepRegistration = registry.tools.add(exampleTool("grep", "Search files")); + +// `models` is pi-ai's model access; generation uses it in later packages. +const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context); + +// The root conversation always has ID 1. The first root() call creates it, +// its configuration, and whatever `init` writes, all in one commit. Later +// calls, including after a restart, return it and ignore `init`. +const root = await harness.root(context, { + init: async (tx, rootId) => { + (await tx.doc(Notes, rootId)).text = "root notes"; + (await tx.doc(ConversationConfig, rootId)).thinkingLevel = "low"; + }, +}); +console.log("7. root:", root.id, await harness.snapshot(Notes, root.id, context)); +console.log("7. root config:", root.id, await harness.snapshot(ConversationConfig, root.id, context)); + +// ─── 8. Conversation configuration ────────────────────────────────────────── +// 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("8. 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("8. snippet kept on the app tool:", registry.tools.list()[0]!.snippet); +console.log("8. model:", await root.getModel(context), "thinking:", await root.getThinkingLevel(context)); + +// Adding a name that is not registered is rejected, and nothing is written. +await root + .setActiveTools(["read", "find"], context) + .catch((error: Error) => console.log("8. 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("8. active tools without a registered write:", await root.getActiveTools(context)); +console.log( + "8. registered tools:", + registry.tools.list().map((entry) => entry.name), +); + +// ─── 9. Conversations and forks ───────────────────────────────────────────── +// Conversation handles are stateless; compare them by id. They bind commits +// to their conversation. +const MessageEntry = defineEntry("message"); +const hello = await root.commit( + (tx) => tx.appendEntry(root.id, { kind: "message", model: [{ role: "user", content: "hello", timestamp: 1 }] }), + context, +); +console.log("9. typed entry:", MessageEntry.is(hello)); + +// createConversation() and fork() run `init` in the creating commit. A fork +// starts with the configuration the parent had at the fork entry. +const helper = await harness.createConversation( + { + ownership: { kind: "ownerless" }, + init: async (tx, id) => { + (await tx.doc(ConversationConfig, id)).activeTools = ["read"]; + }, + }, + context, +); +const retry = await root.fork(hello.id, { ownership: { kind: "ownerless" } }, context); +console.log("9. helper tools:", await helper.getActiveTools(context)); +console.log("9. fork thinking:", await retry.getThinkingLevel(context)); +console.log("9. lookup:", (await harness.conversation(retry.id, context))?.id === retry.id); + +// ─── 10. Transcript history and model context ─────────────────────────────── +// Entries are immutable. `model` holds the messages an entry contributes to +// the next model request; `data` is for the app only. context() turns the +// stored transcript into those request messages: +// - an entry with `head` starts a new context; older entries stay stored, +// - `edits` replace or omit what an earlier entry contributes, +// - aborted, error, and deferred assistant messages are not sent, +// - tool results are sent right after their call, in call order, +// - a call without a result gets a synthesized error result. +function assistantMessage(text: string, calls: readonly string[] = [], stopReason?: StopReason): AssistantMessage { + return { + role: "assistant", + content: [ + { type: "text", text }, + ...calls.map((id) => ({ type: "toolCall" as const, id, name: "read", arguments: {} })), + ], + api: "example", + provider: "example", + model: "example", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: stopReason ?? (calls.length > 0 ? "toolUse" : "stop"), + timestamp: 2, + }; +} + +function toolResultMessage(id: string): ToolResultMessage { + return { + role: "toolResult", + toolCallId: id, + toolName: "read", + content: [{ type: "text", text: `file ${id}` }], + isError: false, + timestamp: 3, + }; +} + +function show(message: Message): string { + switch (message.role) { + case "user": + return `user: ${message.content as string}`; + case "system": + return `system: ${JSON.stringify(message.sections)}`; + case "assistant": + return `assistant: ${message.content + .map((part) => (part.type === "text" ? part.text : part.type === "toolCall" ? `call(${part.id})` : "")) + .join(" ")}`; + case "toolResult": + return `result(${message.toolCallId})${message.isError ? " error" : ""}`; + } +} + +const transcript = await harness.createConversation({ ownership: { kind: "ownerless" } }, context); +const say = (kind: string, ...model: Message[]) => + transcript.commit((tx) => tx.appendEntry(transcript.id, { kind, model }), context); + +const question = await say("message", { role: "user", content: "read a and b", timestamp: 1 }); +await say("message", assistantMessage("I crashed", [], "aborted")); // stored, never sent +const calls = await say("message", assistantMessage("reading", ["a", "b"])); +await say("message", toolResultMessage("b")); // results finish out of order +await say("pi.system", { role: "system", content: "", sections: { cwd: "/repo" }, timestamp: 4 }); +await say("message", toolResultMessage("a")); +await say("message", assistantMessage("a and b look fine")); +await transcript.commit( + (tx) => + tx.appendEntry(transcript.id, { + kind: "edit", + data: "user fixed a typo", + edits: [ + { + target: question.id, + action: "replace", + messages: [{ role: "user", content: "read files a and b", timestamp: 1 }], + }, + ], + }), + context, +); +await transcript.commit((tx) => tx.appendEntry(transcript.id, { kind: "note", data: "display only" }), context); + +let transcriptView = await transcript.context(context); +console.log( + "10. raw active entries:", + transcriptView.entries.map((entry) => entry.kind), +); +console.log("10. request messages:", transcriptView.messages.map(show)); + +// A fork at the tool call has no results yet; context() fills them in. +const cut = await transcript.fork(calls.id, { ownership: { kind: "ownerless" } }, context); +console.log("10. fork messages:", (await cut.context(context)).messages.map(show)); + +// A headed summary replaces everything before the entry it points at. +// "self" points the head at the summary entry itself. +await transcript.commit( + (tx) => + tx.appendEntry(transcript.id, { + kind: "summary", + head: "self", + model: [{ role: "user", content: "Summary: a and b are fine.", timestamp: 5 }], + }), + context, +); +transcriptView = await transcript.context(context); +console.log("10. after summary:", transcriptView.head?.kind, transcriptView.messages.map(show)); + +// entries() pages the stored transcript, newest first, including inherited +// parent entries. Nothing is ever deleted by heads or edits. +const history = await transcript.entries({}, 3, undefined, context); +console.log( + "10. newest stored entries:", + history.items.map((entry) => entry.kind), + "more:", + history.next !== undefined, +); + +// ─── 11. Reload extension code ────────────────────────────────────────────── +// 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(() => { + grepRegistration.dispose(); + registry.tools.add(exampleTool("grep", "Search files, faster")); +}); +console.log( + "11. tools after reload:", + registry.tools.list().map((entry) => `${entry.name}: ${entry.description}`), +); + +// ─── 12. Register system prompt sections ──────────────────────────────────── +// 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: "/" }), +}); +await root.commit(async (tx) => { + (await tx.doc(AgentProfile, root.id)).cwd = "/repo"; +}, context); + +const prompt = registry.batch(() => { + // `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."; + }); + // Sections see the offered tools, including the app's own metadata. + registry.systemPrompt.section("tools", (input) => + input.tools.length === 0 + ? undefined + : input.tools.map((entry) => `- ${entry.name}: ${entry.snippet ?? entry.description}`).join("\n"), + ); +}); + +// 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.`, +})); + +// Package 15's request preparation runs these for every request. This loop +// only stands in for it to show the output. +const promptSnapshot = registry.snapshot(); +const input: PromptInput = { + conversationId: root.id, + tools: promptSnapshot.tools(), + shown: {}, + thinkingLevel: await root.getThinkingLevel(context), + read: harness, +}; +const rendered: string[] = []; +for (const section of promptSnapshot.sections()) { + const text = await section.render(input, context); + if (text === undefined) continue; + rendered.push(section.tag === false ? text : `<${section.key}>\n${text}\n`); +} +console.log(`12. rendered prompt:\n${rendered.join("\n")}`); + +prompt.dispose(); + +await harness.close(context); diff --git a/packages/durable/test/session-checkpoints-migrations.test.ts b/packages/durable/test/session-checkpoints-migrations.test.ts index 9ac677e2f..0482b8eb7 100644 --- a/packages/durable/test/session-checkpoints-migrations.test.ts +++ b/packages/durable/test/session-checkpoints-migrations.test.ts @@ -732,3 +732,74 @@ describe("Session historical document snapshots", () => { await expect(session.snapshotAsOf(Doc, conversationId, recreatedAt, context)).rejects.toThrow("closed"); }); }); + +describe("Session tracker cache across definition versions", () => { + type V1 = { name: string }; + type V2 = { names: string[] }; + const V1Doc = defineDoc({ + kind: "cache.versioned", + version: 1, + scope: "session", + initial: () => ({ name: "first" }), + }); + const V2Doc = defineDoc({ + kind: "cache.versioned", + version: 2, + scope: "session", + initial: () => ({ names: [] }), + migrate: (value) => ({ names: [value.name as string] }), + }); + + it("migrates a document cached by an older token without unloading", async () => { + const { session, storage } = openTestSession(); + await session.commit((tx) => tx.doc(V1Doc), context); + expect(await session.snapshot(V1Doc, context)).toEqual({ name: "first" }); + + // Reloaded extension code accesses the still-cached document with a newer token. + expect(await session.snapshot(V2Doc, context)).toEqual({ names: ["first"] }); + await session.commit(async (tx) => { + (await tx.doc(V2Doc)).names.push("second"); + }, context); + const write = documentWrites(storage.commits.at(-1)!)[0]!; + expect(write).toMatchObject({ + type: "document.change", + content: { version: 2, kind: "base", value: { names: ["first", "second"] } }, + }); + await expect(session.snapshot(V1Doc, context)).rejects.toThrow("newer version 2"); + }); + + it("sends observers of an older shape a root replacement after a newer token writes", async () => { + const { session } = openTestSession(); + await session.commit((tx) => tx.doc(V1Doc), context); + const state = (await session.documentState(V1Doc, context))!; + const watch = (await session.watchDoc(V1Doc, context))!; + const frames: (readonly unknown[])[] = []; + const delivered = new Promise((resolve) => { + watch.start(async (_value, ops) => { + frames.push(ops); + resolve(); + }); + }); + await session.commit(async (tx) => { + (await tx.doc(V2Doc)).names.push("second"); + }, context); + await delivered; + expect(state.value).toEqual({ names: ["first", "second"] }); + expect(watch.value).toEqual({ names: ["first", "second"] }); + expect(frames).toEqual([[["r", { names: ["first", "second"] }]]]); + state.dispose(); + await watch.stop(); + }); + + it("serves an older token from Storage after a newer token migrated only in memory", async () => { + const { session } = openTestSession(); + await session.commit((tx) => tx.doc(V1Doc), context); + await session.unloadDocuments(); + expect(await session.snapshot(V2Doc, context)).toEqual({ names: ["first"] }); + expect(await session.snapshot(V1Doc, context)).toEqual({ name: "first" }); + await session.commit(async (tx) => { + (await tx.doc(V1Doc)).name = "renamed"; + }, context); + expect(await session.snapshot(V2Doc, context)).toEqual({ names: ["renamed"] }); + }); +}); diff --git a/packages/durable/test/session-support.ts b/packages/durable/test/session-support.ts index 9c2edc9cd..e5096c552 100644 --- a/packages/durable/test/session-support.ts +++ b/packages/durable/test/session-support.ts @@ -13,7 +13,7 @@ import { type Seq, type StorageWrite, } from "@earendil-works/pi-durable"; -import { SessionKernel } from "../src/session/session.ts"; +import { SessionImpl } from "../src/session/session.ts"; export const context: Context = BACKGROUND_CONTEXT; @@ -110,11 +110,11 @@ export class ControlledStorage extends MemoryStorage { /** Session kernel plus its controlled storage and every committed publication. */ export function openTestSession(): { readonly storage: ControlledStorage; - readonly session: SessionKernel; + readonly session: SessionImpl; readonly publications: CommitPublication[]; } { const storage = new ControlledStorage(); - const session = new SessionKernel(storage); + const session = new SessionImpl(storage); const publications: CommitPublication[] = []; session.subscribeCommits((publication) => { publications.push(publication); @@ -140,7 +140,7 @@ export function documentCopyChanges( } /** Create one conversation and return its ID. */ -export async function createConversation(session: SessionKernel): Promise { +export async function createConversation(session: SessionImpl): Promise { return session.commit(async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, context); }