mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
c90d9ea586
commit
3883fcb1f3
21 changed files with 3021 additions and 964 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
11
packages/durable/src/entries.ts
Normal file
11
packages/durable/src/entries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
packages/durable/src/harness/config.ts
Normal file
21
packages/durable/src/harness/config.ts
Normal 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,
|
||||
});
|
||||
115
packages/durable/src/harness/context.ts
Normal file
115
packages/durable/src/harness/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
244
packages/durable/src/harness/harness.ts
Normal file
244
packages/durable/src/harness/harness.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
335
packages/durable/src/harness/registry.ts
Normal file
335
packages/durable/src/harness/registry.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
320
packages/durable/src/harness/types.ts
Normal file
320
packages/durable/src/harness/types.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
135
packages/durable/test/harness-context.test.ts
Normal file
135
packages/durable/test/harness-context.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
392
packages/durable/test/harness-conversations.test.ts
Normal file
392
packages/durable/test/harness-conversations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
233
packages/durable/test/harness-registry.test.ts
Normal file
233
packages/durable/test/harness-registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
104
packages/durable/test/harness-support.ts
Normal file
104
packages/durable/test/harness-support.ts
Normal 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(",")}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue