feat(durable): add openable Harness with registry and conversation config

Implements Pico5 Package 13: Harness.open over a Session with an application-owned
registry (tools, tool wrappers, hooks, tasks, system prompt sections; batched
publication, stable keyed order), lazy root creation with init, atomic
create/fork with init, conversation-bound commits, fork-aware entries, model
context derivation, and the built-in ConversationConfig document.

Fixes cached documents skipping migration when accessed with another definition
version. Rewrites the Pico5 spec and handoff to the registry/system-prompt
design and removes the superseded live-registries proposal.
This commit is contained in:
Mario Zechner 2026-09-28 21:57:29 +02:00
parent c90d9ea586
commit 3883fcb1f3
21 changed files with 3021 additions and 964 deletions

View file

@ -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

View file

@ -126,10 +126,10 @@ async function runCanvasExample(session: Session): Promise<void> {
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

View file

@ -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

View file

@ -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<void>;
};
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.

View file

@ -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<SubmissionDraft, { readonly type: "input" }>;
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<void>;
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<Tool extends ToolRegistration = ToolRegistration> = {
readonly models: Models; // the pi-ai Models interface
readonly tools?: readonly ToolRegistration[];
readonly taskKinds?: readonly AnyTask[];
readonly sections?: readonly SystemSection<JsonValue>[];
readonly registry: RegistryReader<Tool>; // 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<ConversationConfigState>;
interface Entry<E extends EntryRecord = EntryRecord> {
readonly kind: string;
is(entry: EntryRecord | undefined): entry is E;
@ -379,15 +384,6 @@ interface Conversation {
setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise<void>;
getActiveTools(context: Context): Promise<readonly string[]>;
setActiveTools(names: readonly string[], context: Context): Promise<void>;
getSection<T extends JsonValue>(
section: SystemSection<T>,
context: Context,
): Promise<T | undefined>;
setSection<T extends JsonValue>(
section: SystemSection<T>,
value: T | undefined,
context: Context,
): Promise<void>;
commit<T>(
change: (tx: Tx) => T | Promise<T>,
@ -402,19 +398,13 @@ interface Conversation {
): Promise<Page<EntryRecord, Cursor>>;
fork(
at: EntryId,
spec: ConversationCreateSpec,
options: ConversationCreateOptions,
context: Context,
): Promise<Conversation>;
collapse(instructions: string | undefined, context: Context): Promise<TaskId>;
reset(handoff: string | undefined, context: Context): Promise<void>;
abort(context: Context): Promise<void>;
waitForIdle(context: Context): Promise<void>;
hooks<K extends AnyTask>(
owner: string,
task: K,
handlers: Partial<HooksOf<K>>,
options?: { readonly subtree?: boolean },
): () => void;
viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>>;
watch(context: Context): Promise<ConversationWatch>;
}
@ -425,20 +415,13 @@ interface Harness extends Session {
quiescent(): boolean;
hold(): () => void;
registerTaskKind(task: AnyTask): () => void;
registerTool(tool: ToolRegistration): () => void;
registerSection(section: SystemSection<JsonValue>): () => void;
hooks<K extends AnyTask>(
owner: string,
task: K,
handlers: Partial<HooksOf<K>>,
): () => void;
root(context: Context): Promise<Conversation>;
onConversation(listener: (conversation: Conversation) => void): () => void;
root(
context: Context,
options?: { readonly init?: ConversationInit },
): Promise<Conversation>;
conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
createConversation(
spec: ConversationCreateSpec & { readonly input?: UserInput },
options: ConversationCreateOptions & { readonly input?: UserInput },
context: Context,
): Promise<Conversation>;
@ -456,9 +439,9 @@ interface Harness extends Session {
}
declare const Harness: {
open(
open<Tool extends ToolRegistration>(
storage: Storage,
options: HarnessOptions,
options: HarnessOptions<Tool>,
context: Context,
): Promise<Harness>;
};
@ -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<Session, "snapshot" | "snapshotAsOf">;
interface Registration {
/** Idempotent; removes exactly the registrations this token covers. */
dispose(): void;
}
type ToolWrapper<Tool extends ToolRegistration> = (tool: Tool) => Tool;
type HookScope = {
readonly conversationId: ConversationId;
/** Also match conversations owned, transitively, by tasks of this conversation. */
readonly subtree?: boolean;
};
type PromptInput<Tool extends ToolRegistration> = {
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<Record<string, string>>;
readonly model?: ModelRef;
readonly thinkingLevel: ModelThinkingLevel;
/** Committed document reads. */
readonly read: DocumentReader;
};
type PromptSection<Tool extends ToolRegistration> = {
readonly key: string;
render(
input: PromptInput<Tool>,
context: Context,
): string | undefined | Promise<string | undefined>;
/** Default true: wrap the text as `<key>\n...\n</key>`. */
readonly tag?: boolean;
};
type PromptSectionWrapper<Tool extends ToolRegistration> = (
section: PromptSection<Tool>,
) => PromptSection<Tool>;
interface RegistryReader<Tool extends ToolRegistration = ToolRegistration> {
/** Immutable view of the whole current registry. */
snapshot(): RegistrySnapshot<Tool>;
/** Called synchronously after every publication; wakes the scheduler to reconsider blocked tasks. */
subscribe(listener: () => void): () => void;
}
interface RegistrySnapshot<Tool extends ToolRegistration> {
/** 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<K extends AnyTask>(task: K): readonly {
readonly handlers: Partial<HooksOf<K>>;
readonly scope?: HookScope;
}[];
/** Composed sections in registry order; a section whose wrapper failed is absent. */
sections(): readonly PromptSection<Tool>[];
/** 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<Tool extends ToolRegistration = ToolRegistration> extends RegistryReader<Tool> {
readonly tools: {
add(tool: Tool): Registration;
wrap(name: string, key: string, wrapper: ToolWrapper<Tool>): Registration;
list(): readonly Tool[];
};
readonly hooks: {
add<K extends AnyTask>(
task: K,
handlers: Partial<HooksOf<K>>,
options?: { readonly scope?: HookScope; readonly key?: string },
): Registration;
};
readonly tasks: {
add(task: AnyTask): Registration;
list(): readonly AnyTask[];
};
readonly systemPrompt: {
section(
key: string,
render: PromptSection<Tool>["render"],
options?: { readonly tag?: boolean },
): Registration;
wrap(key: string, wrapperKey: string, wrapper: PromptSectionWrapper<Tool>): Registration;
sections(): readonly PromptSection<Tool>[];
};
batch(register: () => void): Registration;
}
function createRegistry<Tool extends ToolRegistration = ToolRegistration>(): Registry<Tool>;
```
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<void>;
}
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<void>;
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
progress(value: JsonObject, context: Context): Promise<void>;
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
createTask<I, S extends { phase: string }, R, H extends object>(
@ -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<T extends JsonValue = JsonValue> {
readonly key: string;
render(value: T): string;
}
function defineSystemSection<T extends JsonValue>(definition: {
readonly key: string;
render(value: T): string;
}): SystemSection<T>;
```
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 `<key>\n...\n</key>`. 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.

View file

@ -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<E extends EntryRecord>(kind: string): Entry<E> {
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,
};
}

View file

@ -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<ConversationConfigState>({
kind: "pi.conversation.config",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ thinkingLevel: "off", activeTools: [] }),
checkpointWhen: () => true,
});

View file

@ -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<AssistantMessage["stopReason"]> = 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<ContextBounds | undefined> {
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<ContextView> {
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<EntryId, ContextEdit>();
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<string, number>();
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,
};
}

View file

@ -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<Tool extends ToolRegistration> = {
readonly harness: HarnessImpl<Tool>;
readonly storage: Storage;
readonly registry: RegistryReader<Tool>;
create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise<Conversation>;
};
class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
readonly id: ConversationId;
readonly #host: ConversationHost<Tool>;
constructor(id: ConversationId, host: ConversationHost<Tool>) {
this.id = id;
this.#host = host;
}
async getModel(context: Context): Promise<ModelRef | undefined> {
return (await this.#config(context)).model;
}
setModel(model: ModelRef | undefined, context: Context): Promise<void> {
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<ModelThinkingLevel> {
return (await this.#config(context)).thinkingLevel;
}
setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise<void> {
return this.#editConfig((config) => {
config.thinkingLevel = level;
}, context);
}
async getActiveTools(context: Context): Promise<readonly string[]> {
return (await this.#config(context)).activeTools;
}
setActiveTools(names: readonly string[], context: Context): Promise<void> {
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<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T> {
return this.#host.harness.commitWith(change, context, this.id);
}
async context(context: Context): Promise<ContextView> {
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<EntryQuery, "conversationId">,
limit: number,
cursor: Cursor | undefined,
context: Context,
): Promise<Page<EntryRecord, Cursor>> {
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<Conversation> {
return this.#host.create(
{ kind: "fork", parentId: this.id, at, ownership: options.ownership },
options.init,
context,
);
}
async #config(context: Context): Promise<Readonly<ConversationConfigState>> {
return (
(await this.#host.harness.snapshot(ConversationConfig, this.id, context)) ??
ConversationConfig.definition.initial()
);
}
#editConfig(edit: (config: Draft<ConversationConfigState>) => void, context: Context): Promise<void> {
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<Tool extends ToolRegistration> extends SessionImpl implements HarnessType {
readonly #storage: Storage;
readonly #registry: RegistryReader<Tool>;
readonly #host: ConversationHost<Tool>;
#closed = false;
constructor(storage: Storage, options: HarnessOptions<Tool>) {
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<Conversation> {
return this.#create({ kind: "root" }, options?.init, context);
}
async conversation(id: ConversationId, context: Context): Promise<Conversation | undefined> {
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<Conversation> {
return this.#create({ kind: "independent", ownership: options.ownership }, options.init, context);
}
override close(context: Context): Promise<void> {
this.#closed = true;
return super.close(context);
}
async #create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise<Conversation> {
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<ConversationId> {
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<Tool extends ToolRegistration>(
snapshot: RegistrySnapshot<Tool>,
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<Tool extends ToolRegistration>(
storage: Storage,
options: HarnessOptions<Tool>,
context: Context,
): Promise<Harness> {
context.abortSignal?.throwIfAborted();
return new HarnessImpl(storage, options);
},
};

View file

@ -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<Tool extends ToolRegistration> =
| { readonly kind: "tool"; readonly tool: Tool }
| { readonly kind: "toolWrap"; readonly name: string; readonly wrapper: ToolWrapper<Tool> }
| { readonly kind: "hook"; readonly taskName: string; readonly hook: StoredHook }
| { readonly kind: "task"; readonly task: AnyTask }
| { readonly kind: "section"; readonly section: PromptSection<Tool> }
| { readonly kind: "sectionWrap"; readonly key: string; readonly wrapper: PromptSectionWrapper<Tool> };
/** 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<Tool extends ToolRegistration> = {
readonly slot: Slot<Tool>;
/** 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<Tool extends ToolRegistration> = {
/** Records to publish; a record disposed while staged is removed. */
readonly added: RegistryRecord<Tool>[];
readonly disposed: Set<RegistryRecord<Tool>>;
};
type Composition<Tool extends ToolRegistration> = {
readonly tools: readonly Tool[];
readonly toolsByName: ReadonlyMap<string, Tool>;
readonly sections: readonly PromptSection<Tool>[];
readonly failures: readonly RegistryFailure[];
};
/** Immutable published registry state; wrappers are composed lazily once per state. */
class RegistryState<Tool extends ToolRegistration> implements RegistrySnapshot<Tool> {
readonly records: readonly RegistryRecord<Tool>[];
#composition: Composition<Tool> | undefined;
constructor(records: readonly RegistryRecord<Tool>[]) {
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<K extends AnyTask>(task: K): readonly HookRegistration<K>[] {
// 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<K>[];
}
sections(): readonly PromptSection<Tool>[] {
return this.#composed().sections;
}
failures(): readonly RegistryFailure[] {
return this.#composed().failures;
}
#composed(): Composition<Tool> {
if (this.#composition !== undefined) return this.#composition;
const failures: RegistryFailure[] = [];
const toolWraps = new Map<string, ToolWrapper<Tool>[]>();
const sectionWraps = new Map<string, PromptSectionWrapper<Tool>[]>();
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<string, Tool>();
const sections: PromptSection<Tool>[] = [];
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<Tool extends ToolRegistration> implements Registry<Tool> {
#current = new RegistryState<Tool>([]);
#batch: Batch<Tool> | undefined;
/** First position of every key ever published; re-registered keys keep it. */
readonly #positions = new Map<string, number>();
#nextPosition = 0;
readonly tools: Registry<Tool>["tools"];
readonly hooks: Registry<Tool>["hooks"];
readonly tasks: Registry<Tool>["tasks"];
readonly systemPrompt: Registry<Tool>["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: <K extends AnyTask>(
task: K,
handlers: Partial<HooksOf<K>>,
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<Tool> =
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<Tool> {
return this.#current;
}
batch(register: () => void): Registration {
if (this.#batch !== undefined) throw new Error("Registry batches cannot be nested");
const batch: Batch<Tool> = { added: [], disposed: new Set() };
this.#batch = batch;
let added: RegistryRecord<Tool>[];
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<Tool>, key: string | undefined, label: string): Registration {
const record: RegistryRecord<Tool> = { 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<Tool>): 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<Tool>): 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<string>();
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<Tool extends ToolRegistration = ToolRegistration>(): Registry<Tool> {
return new RegistryImpl<Tool>();
}
function appendTo<T>(map: Map<string, T[]>, 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<unknown>).then(undefined, () => undefined);
throw new TypeError("Registry batch callbacks must be synchronous");
}
}

View file

@ -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<SubmissionDraft, { readonly type: "input" }>;
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<SubmissionRecord>;
wait(context: Context): Promise<SettledSubmissionRecord>;
abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;
}
export type SettledTask<R> = TaskRecord<JsonValue, JsonValue, R> & {
readonly state: Extract<TaskState<JsonValue, R>, { 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<Record<string, unknown>>;
readonly abort: unknown;
readonly migrate?: unknown;
readonly hooks?: object;
};
};
/** Hook handler map declared by a task definition. */
export type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H> ? H : never;
/** Committed document reads. */
export type DocumentReader = Pick<Session, "snapshot" | "snapshotAsOf">;
/** Invocation-bound conversation operations available to tools. */
export interface ConversationHandle {
readonly id: ConversationId;
submit(submission: InputSubmissionDraft, context: Context): Promise<Submission>;
abort(context: Context): Promise<void>;
waitForIdle(context: Context): Promise<void>;
}
/** 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<void>;
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
createTask<I, S extends { phase: string }, R, H extends object>(
task: Task<I, S, R, H>,
input: I,
options: Omit<TaskOptions, "conversationId">,
context: Context,
): Promise<TaskId<R>>;
getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
}
/** 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<ToolExecutionResult>;
};
/** 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 extends ToolRegistration> = (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<Tool extends ToolRegistration> = {
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<Record<string, string>>;
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<Tool extends ToolRegistration> = {
readonly key: string;
render(input: PromptInput<Tool>, context: Context): string | undefined | Promise<string | undefined>;
/** Default true: wrap the text as `<key>\n...\n</key>`. */
readonly tag?: boolean;
};
/** Pure decorator; returns a new section with the same key and never mutates its input. */
export type PromptSectionWrapper<Tool extends ToolRegistration> = (section: PromptSection<Tool>) => PromptSection<Tool>;
/** One registered hook handler map for task `K`. */
export type HookRegistration<K extends AnyTask> = {
readonly handlers: Partial<HooksOf<K>>;
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<Tool extends ToolRegistration = ToolRegistration> {
/** 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<K extends AnyTask>(task: K): readonly HookRegistration<K>[];
/** Composed sections in registry order; a section whose wrapper failed is absent. */
sections(): readonly PromptSection<Tool>[];
/** Wrapper failures of this state. */
failures(): readonly RegistryFailure[];
}
/** Read side of a registry consumed by a Harness. */
export interface RegistryReader<Tool extends ToolRegistration = ToolRegistration> {
/** Immutable view of the whole current registry. */
snapshot(): RegistrySnapshot<Tool>;
}
/** Application-owned registry of tools, hooks, tasks, and the system prompt. */
export interface Registry<Tool extends ToolRegistration = ToolRegistration> extends RegistryReader<Tool> {
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<Tool>): Registration;
/** Composed tools of the current state. */
list(): readonly Tool[];
};
readonly hooks: {
add<K extends AnyTask>(
task: K,
handlers: Partial<HooksOf<K>>,
options?: { readonly scope?: HookScope; readonly key?: string },
): Registration;
};
readonly tasks: {
add(task: AnyTask): Registration;
list(): readonly AnyTask[];
};
readonly systemPrompt: {
section(key: string, render: PromptSection<Tool>["render"], options?: { readonly tag?: boolean }): Registration;
wrap(key: string, wrapperKey: string, wrapper: PromptSectionWrapper<Tool>): Registration;
/** Composed sections of the current state. */
sections(): readonly PromptSection<Tool>[];
};
/** 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<void>;
export type ConversationCreateOptions = {
readonly ownership: ConversationOwnership;
readonly init?: ConversationInit;
};
export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
/** pi-ai model access used by generation. */
readonly models: Models;
readonly registry: RegistryReader<Tool>;
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<E extends EntryRecord = EntryRecord> {
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<ModelRef | undefined>;
setModel(model: ModelRef | undefined, context: Context): Promise<void>;
getThinkingLevel(context: Context): Promise<ModelThinkingLevel>;
setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise<void>;
getActiveTools(context: Context): Promise<readonly string[]>;
setActiveTools(names: readonly string[], context: Context): Promise<void>;
/** Session commit whose `tx.createTask()` defaults to this conversation. */
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
context(context: Context): Promise<ContextView>;
/** Newest-first fork-aware history of this conversation. */
entries(
query: Omit<EntryQuery, "conversationId">,
limit: number,
cursor: Cursor | undefined,
context: Context,
): Promise<Page<EntryRecord, Cursor>>;
fork(at: EntryId, options: ConversationCreateOptions, context: Context): Promise<Conversation>;
}
// 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>;
// 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<Conversation | undefined>;
createConversation(options: ConversationCreateOptions, context: Context): Promise<Conversation>;
}

View file

@ -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 {

View file

@ -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<string, LoadedDocument>();
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<T>(
conversationId: ConversationId,
/** Internal commit whose `tx.createTask()` defaults to `defaultConversationId`; used by Conversation handles. */
commitWith<T>(
change: (tx: Tx) => T | Promise<T>,
context: Context,
defaultConversationId?: ConversationId,
): Promise<T> {
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<T>(change: (tx: Transaction) => T | Promise<T>, context: Context): Promise<T> {
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<T>(job: () => Promise<T>): Promise<T> {
try {
this.#assertUsable();
} catch (error) {
return Promise.reject(error);
}
return this.#enqueue(async () => {
this.#assertHealthy();
return job();
});
}
snapshot<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<Readonly<T> | 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<T>(
change: (tx: Tx) => T | Promise<T>,
change: (tx: Transaction) => T | Promise<T>,
context: Context,
defaultConversationId?: ConversationId,
): Promise<T> {
@ -447,7 +466,9 @@ export class SessionKernel implements Session {
context: Context,
): Promise<LoadedDocument | undefined> {
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<DocumentCommitChange, { readonly type: "document" }>,
): 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");
}

View file

@ -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<JsonObject>;
@ -550,6 +552,7 @@ export class Transaction implements Tx {
addressId: document.addressId,
record,
storedVersion: target.version,
valueVersion: target.version,
deltasSinceBase: 0,
tracker: target.tracker,
});

View file

@ -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<EntryRecord> =>
root.commit((tx) => tx.appendEntry(root.id, draft), context);
const message = (model: Message, kind = "message"): Promise<EntryRecord> => 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"]);
});
});

View file

@ -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<string>();
async function sqlitePath(): Promise<string> {
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<EntryRecord & { readonly kind: "message" }>("message");
async function append(conversation: Conversation, text: string): Promise<EntryRecord> {
return conversation.commit(
(tx) => tx.appendEntry(conversation.id, { kind: "message", model: [user(text)] }),
context,
);
}
async function allEntries(conversation: Conversation): Promise<string[]> {
const texts: string[] = [];
let cursor: Parameters<Conversation["entries"]>[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);
});
});

View file

@ -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> = {}): 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<AppTool>();
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<typeof registry.tools.add> | 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<AppTool>();
registry.tools.add(tool("echo", { execute, snippet: "Echo" }));
expect(registry.tools.list()[0]!.execute).toBe(execute);
});
});

View file

@ -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<string, string | null>): 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(",")}`;
}
}

View file

@ -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<AppTool>();
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<EntryRecord & { readonly kind: "message" }>("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: "<cwd>/repo</cwd>" }, timestamp: 4 });
await say("message", toolResultMessage("a"));
await say("message", assistantMessage("a and b look fine"));
await transcript.commit(
(tx) =>
tx.appendEntry(transcript.id, {
kind: "edit",
data: "user fixed a typo",
edits: [
{
target: question.id,
action: "replace",
messages: [{ role: "user", content: "read files a and b", timestamp: 1 }],
},
],
}),
context,
);
await transcript.commit((tx) => tx.appendEntry(transcript.id, { kind: "note", data: "display only" }), context);
let 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 <key>...</key>.
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<AppTool> = {
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</${section.key}>`);
}
console.log(`12. rendered prompt:\n${rendered.join("\n")}`);
prompt.dispose();
await harness.close(context);

View file

@ -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<V1>({
kind: "cache.versioned",
version: 1,
scope: "session",
initial: () => ({ name: "first" }),
});
const V2Doc = defineDoc<V2>({
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<void>((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"] });
});
});

View file

@ -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<ConversationId> {
export async function createConversation(session: SessionImpl): Promise<ConversationId> {
return session.commit(async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id, context);
}