From f467cf7068ade93f8820e36dce02675860edb4ee Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 12 Sep 2026 00:02:58 +0200 Subject: [PATCH] feat(agent): add pico compile-time foundation --- .../agent/docs/pico/pico-simple-blockers.md | 21 +- .../agent/docs/pico/pico-simple-handoff.md | 728 +++++++++++------- packages/agent/src/harness/pico/addresses.ts | 77 ++ packages/agent/src/harness/pico/core.ts | 8 + packages/agent/src/harness/pico/entries.ts | 66 ++ packages/agent/src/harness/pico/index.ts | 5 + packages/agent/src/harness/pico/runtime.ts | 300 ++++++++ packages/agent/src/harness/pico/tasks.ts | 207 +++++ .../agent/test/harness/pico/tsconfig.json | 7 + .../agent/test/harness/pico/types.compile.ts | 379 +++++++++ 10 files changed, 1507 insertions(+), 291 deletions(-) create mode 100644 packages/agent/src/harness/pico/addresses.ts create mode 100644 packages/agent/src/harness/pico/core.ts create mode 100644 packages/agent/src/harness/pico/entries.ts create mode 100644 packages/agent/src/harness/pico/index.ts create mode 100644 packages/agent/src/harness/pico/runtime.ts create mode 100644 packages/agent/src/harness/pico/tasks.ts create mode 100644 packages/agent/test/harness/pico/tsconfig.json create mode 100644 packages/agent/test/harness/pico/types.compile.ts diff --git a/packages/agent/docs/pico/pico-simple-blockers.md b/packages/agent/docs/pico/pico-simple-blockers.md index 878de520f..bd3da1ae7 100644 --- a/packages/agent/docs/pico/pico-simple-blockers.md +++ b/packages/agent/docs/pico/pico-simple-blockers.md @@ -5,24 +5,22 @@ [`pico-simple-handoff.md`](pico-simple-handoff.md) is the sole normative implementation specification. Read it completely before implementation. Other Pico documents and prototypes are historical inputs only. -Astra reviewer `e5771f` reviewed the complete rewritten specification through multiple iterations and -returned explicit `APPROVED`. Keep the reviewer available for implementation follow-up. +The original specification and WP1 received delegated review. Peer review then identified and resolved +TaskOutput contract inconsistencies in JSONL replay, mutation failure handling, watch reconnection, +kind replacement and generation partial state. ## Git state - Branch: `pico` -- Branch base when created: `f3c672245` -- No production implementation has started. -- The documentation preservation commit includes the pre-implementation Pico design cleanups and the - approved simple handoff. +- Branch base when created: `f3c672245`. +- WP1 implements compile-time declarations under `packages/agent/src/harness/pico/` with focused compile + coverage. - Implement and commit one small work package at a time for user review. ## Next action -Start WP1 from `pico-simple-handoff.md`: core types and kind witnesses under -`packages/agent/src/harness/pico/`, including compile-time tests. Delegate implementation with provider -`openai-codex`, model `gpt-5.6-sol`. Run the focused test and full `npm run check`, then use retained Astra -for review. Commit only WP1 files after the package boundary is approved. +Review the WP1 commit, then begin WP2 only after explicit user approval. WP2 is the mutation algebra and +`MemoryStorage`; follow its prerequisites, acceptance criteria and one-commit boundary in the handoff. ## Adopted core @@ -35,4 +33,5 @@ abort, then a restricted abort closure. manual collapse is background plus turn. Non-turn tasks use limited transactions and `accept`/`write`; turn tasks may append model-affecting entries directly. Every missing live kind becomes orphaned at open. Input groups belong to built-in generation/post_tools. V1 is job-first and excludes arbitrary unfinished -promise adoption. Tool/job output sharing remains gated. +promise adoption. Shared TaskOutput storage/runtime/watch foundations are settled; concrete tool/job +payloads, bounded capture/spill policy and model projection remain gated. diff --git a/packages/agent/docs/pico/pico-simple-handoff.md b/packages/agent/docs/pico/pico-simple-handoff.md index e048d7131..b593b86c0 100644 --- a/packages/agent/docs/pico/pico-simple-handoff.md +++ b/packages/agent/docs/pico/pico-simple-handoff.md @@ -5,8 +5,9 @@ This file is the sole normative implementation specification for the clean-room inspiration only. An implementer must not need them to implement the work packages marked ready here. -The specification intentionally gates provider, tool/output/preview, hook-scratch and client integration -where their APIs are not settled. A gated package must not be implemented by guessing. The storage, +The specification intentionally gates provider, built-in tool schemas, hook-scratch and renderer/client +integration where their APIs are not settled. Task output is part of the ready foundation; there is no +separate preview subsystem. A gated package must not be implemented by guessing. The storage, entry/context, task, transaction, admission, scheduling, recovery and cancellation foundation is fully specified here. @@ -47,11 +48,10 @@ type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; interface JsonObject { readonly [key: string]: JsonValue } type Id = number; -type Seq = number; ``` -IDs and sequences are positive safe integers. One session sequence orders every mutation in every main -and scratch batch. A mutation that creates an object uses its sequence as that object's ID. There are no +IDs and sequence positions are positive safe integers represented by `Id`. One session sequence orders +every mutation in every main, scratch and shared batch. A mutation that creates an object uses its sequence as that object's ID. There are no reserved ranges and IDs are never reused. A transaction starts from committed `lastSeq`. Its first buffered mutation receives `lastSeq + 1`. @@ -63,15 +63,14 @@ snapshot or an equivalently immutable representation. Mutating an object after p never alter buffered, committed or returned historical data. Reads return immutable values or owned copies where the API promises mutation by the caller. -`Call` is Chord context: +Use Chord's `Context` directly: ```ts import type { Context } from "@earendil-works/chord"; -export type Call = Context; ``` Every asynchronous public, runtime, storage-adapter, provider, environment, hook and wait operation takes -a required final `Call`. Synchronous transaction builder methods take no `Call`. +a required final `ctx: Context`. Synchronous transaction builder methods take no context. ## 3. Entries, conversations and context @@ -93,7 +92,7 @@ interface EntryIdentity { readonly byTaskId?: Id; } -interface EntryBase extends EntryIdentity { readonly key?: string } +type EntryBase = EntryIdentity; interface EntryData { readonly data: D } interface ModelProjection { readonly model: readonly M[] } interface ContextHead { readonly head: Id } @@ -105,14 +104,6 @@ type EntryInput = Omit & (E extends ContextHead ? { readonly head: Id | "self" } : { readonly head?: never }); -interface DataEntryInput { - readonly key?: string; - readonly data?: D; - readonly model?: never; - readonly head?: never; - readonly edits?: never; -} - interface EntryKind { readonly kind: string; is(entry: Entry | undefined): entry is E; @@ -140,7 +131,7 @@ type UserEntry = EntryBase & { readonly model: readonly [UserMessage] }; type AssistantEntry = EntryBase & { readonly model: readonly [AssistantMessage] }; type ToolResultEntry = EntryBase & EntryData & { readonly model: readonly [ToolResultMessage] }; type NoticeEntry = EntryBase & Partial & { readonly model: readonly [UserMessage] }; -type SummaryEntry = EntryBase & EntryData<{ readonly summarizedThrough: Id }> & +type SummaryEntry = EntryBase & EntryData<{ readonly through: Id }> & { readonly model: readonly [UserMessage] } & ContextHead; type HandoffEntry = EntryBase & { readonly model: readonly [UserMessage] } & ContextHead; type ResetEntry = EntryBase & ContextHead; @@ -168,8 +159,8 @@ A direct append validates against committed state plus prior buffered mutations: - An assistant message is final-successful when `stopReason` is `"stop"`, `"length"` or `"toolUse"`. `"pending"` and `"deferred"` are not appendable terminal assistant entries. `"error"` and `"aborted"` may be stored for display but do not enter later model requests or create tool work. -- A successful assistant's tool-call IDs are unique. A tool-result entry key equals one visible call ID, - its single message repeats that call ID/name, has one result at most, and belongs to the same conversation. +- A successful assistant's tool-call IDs are unique. A tool-result entry's single message names one visible + call ID/name, has one result at most for that call, and belongs to the same conversation. Trusted turn authors remain responsible for creating the right tasks and input ownership. An exchange consists of one successful assistant entry containing tool calls and its tool-result entries. @@ -237,49 +228,50 @@ results locally but creates or inherits no tasks. A fork link is history, not ow type Scope = | { readonly type: "session" } | { readonly type: "conversation"; readonly conversationId: Id } - | { readonly type: "task"; readonly taskId: Id }; + | { readonly type: "task"; readonly taskId: Id } + | { readonly type: "shared"; readonly id: Id }; -type Collection = "value" | "list"; declare const addressType: unique symbol; interface Address { readonly scope: Scope; readonly namespace: string; readonly key?: string; - readonly collection: Collection; + readonly collection: "value" | "list"; readonly rewind: boolean; readonly [addressType]?: T; } interface Value extends Address { readonly collection: "value" } interface List extends Address { readonly collection: "list" } -interface Version { readonly seq: Seq; readonly value: T } interface Element { readonly id: Id; readonly value: T } -type StickyAddressOptions = { readonly key?: string; readonly rewind?: false }; -type ConversationAddressOptions = { readonly key?: string; readonly rewind: boolean }; -declare function valueAddress( - scope: Extract, namespace: string, - options?: StickyAddressOptions, +type StickyOptions = { readonly key?: string; readonly rewind?: false }; +type ConversationOptions = { readonly key?: string; readonly rewind: boolean }; +declare function defineValue( + scope: Extract, namespace: string, + options?: StickyOptions, ): Value; -declare function valueAddress( +declare function defineValue( scope: Extract, namespace: string, - options: ConversationAddressOptions, + options: ConversationOptions, ): Value; -declare function listAddress( - scope: Extract, namespace: string, - options?: StickyAddressOptions, +declare function defineList( + scope: Extract, namespace: string, + options?: StickyOptions, ): List; -declare function listAddress( +declare function defineList( scope: Extract, namespace: string, - options: ConversationAddressOptions, + options: ConversationOptions, ): List; ``` Addresses are fully bound. Session constructors require no ID; conversation constructors require a -conversation ID; task-scratch constructors require a task ID. Handles validate the bound ID and never -silently replace it. Session addresses are sticky and require `rewind:false`; task addresses are never -rewindable; conversation addresses explicitly select `rewind:true` or `false`. Ready packages compare addresses structurally by the tuple +conversation ID; task-scratch constructors require a task ID; shared constructors require a shared-scope +ID. Handles validate the bound ID and never silently replace it. Session, task and shared addresses are +non-rewindable; conversation addresses explicitly select `rewind:true` or `false`. Task scope is private +scratch retired with that task. Shared scope is capability-referenced and retires when a committed batch +leaves no live task reference. Ready packages compare addresses structurally by the tuple `[scope type, scope id or null, collection, rewind, namespace, key-present, key or null]`. String wire encoding is deferred to client integration. @@ -304,11 +296,12 @@ state. Sticky/session state and task mutations may occur anywhere. A violating b whole transaction rolls back. Namespaces beginning `pi.` are protected. Public/plugin constructors reject them unless created with an -internal built-in authority. Managed system records and input/result/receipt state cannot be written via -generic `record`, value or list handles. +internal built-in authority. Managed system records and input/result/receipt state cannot be written via generic entry, value or list +handles. -Host transactions may use any nonprotected bound address. Normal and abort task transactions may use -session addresses and conversation addresses in the current task's conversation/owned subtree. A task +Ready generic transactions reject shared addresses; the protected central task-output writer is their only +mutation authority. Host transactions may use any nonprotected session/conversation address. Normal and +abort task transactions may use session addresses and conversation addresses in the current task's conversation/owned subtree. A task address is accepted only by that same task's `scratch` API; it is rejected by main transactions and by another task. `TaskConversation.value/list` accepts session addresses and addresses bound to that handle's conversation, and rejects every other conversation/task address. @@ -319,6 +312,27 @@ conversation, and rejects every other conversation/task address. interface TaskCheckpoint extends JsonObject { readonly phase: string } type NoCheckpoint = never; +declare const taskOutputType: unique symbol; // compile-only; durable identity is `kind` +interface TaskOutputKind { + readonly kind: string; + readonly [taskOutputType]?: O; +} +declare function defineTaskOutput(kind: string): TaskOutputKind; +interface TaskOutputSpec { + readonly kind: TaskOutputKind; + initial(input: I): O; +} +interface StoredTaskOutputRef { readonly id: Id; readonly kind: string } +interface TaskOutputRef extends StoredTaskOutputRef { + readonly [taskOutputType]?: O; +} +type TaskOutputField = [O] extends [never] + ? unknown + : { readonly output: TaskOutputRef }; +type TaskOutputDefinition = [O] extends [never] + ? { readonly output?: never } + : { readonly output: TaskOutputSpec }; + type TaskOutcome = | { readonly status: "completed"; readonly result: R } | { readonly status: "failed"; readonly failure: F } @@ -335,6 +349,7 @@ interface TaskBase { readonly background?: true; readonly turn?: true; readonly owns: readonly Id[]; + readonly output?: StoredTaskOutputRef; readonly abort?: true; } @@ -345,8 +360,9 @@ type Task } ); -type RunningTask = - TaskBase & { readonly status: "running"; readonly outcome?: never }; +type RunningTask = + Omit, "output"> & TaskOutputField & + { readonly status: "running"; readonly outcome?: never }; type Completion = | { readonly status: "completed"; readonly result: R } @@ -359,36 +375,37 @@ payloads are kind-specific strict JSON. Structurally identical checkpoint types assignable in TypeScript; kind attribution is supplied by the current runtime, not a nominal durable brand. Differently shaped checkpoints, partial checkpoints and another kind's incompatible shape reject. -A task kind has five payload types and one literal capability: +A task kind has five durable payload types, a sixth task-output state type (`never` means no output), and +one literal turn capability. Output state is a mutable object/array accepted by Chord `track`; runtime +strict-JSON validation rejects functions, undefined, cycles and non-JSON values. `TaskOutputKind` contains +only durable string identity plus a compile-only output witness. `TaskOutputSpec.initial(input)` is +synchronous and pure. It runs only when task creation omits a shared ref, and its base commits atomically +with task creation; it never runs when sharing. ```ts -type Turn = false | true; - -declare const taskTypes: unique symbol; -interface TaskTypes { - readonly [taskTypes]?: { input: I; checkpoint: C; result: R; failure: F; aborted: A }; -} - declare const taskKindBrand: unique symbol; interface TaskKindBase { readonly kind: string; - readonly turn: Turn; + readonly turn: false | true; readonly [taskKindBrand]: true; } -interface TaskKind - extends TaskKindBase, TaskTypes { +interface TaskKindMethods + extends TaskKindBase { readonly turn: T; - execute(task: RunningTask, runtime: RuntimeFor, call: Call): - Promise>; - recover(task: RunningTask, runtime: RuntimeFor, call: Call): - Promise>; - abort(task: RunningTask, runtime: AbortRuntimeFor, call: Call): - Promise>; + execute(task: RunningTask, runtime: RuntimeFor, ctx: Context): + Promise>; + recover(task: RunningTask, runtime: RuntimeFor, ctx: Context): + Promise>; + abort(task: RunningTask, runtime: AbortRuntimeFor, ctx: Context): + Promise>; } +type TaskKind = + TaskKindMethods & TaskOutputDefinition; type NoExtra = Actual & Record, never>; @@ -400,33 +417,38 @@ type ExactJsonInput = : Actual; type NonTurnKindDefinition = - Omit, "turn" | typeof taskKindBrand> & { readonly turn?: false }; + R extends JsonValue, F extends JsonValue, A extends JsonValue, + O extends object> = + Omit, "turn" | typeof taskKindBrand> & + { readonly turn?: false }; type TurnKindDefinition = - Omit, typeof taskKindBrand>; + R extends JsonValue, F extends JsonValue, A extends JsonValue, + O extends object> = + Omit, typeof taskKindBrand>; interface TaskKindFactory { - >( - definition: NoExtra, D>, - ): D & TaskKind; - >( - definition: NoExtra, D>, - ): D & TaskKind; + R extends JsonValue, F extends JsonValue, A extends JsonValue, + O extends object> { + >( + definition: NoExtra, D>, + ): D & TaskKind; + >( + definition: NoExtra, D>, + ): D & TaskKind; } -declare function defineTaskKind(): TaskKindFactory; +declare function defineTask(): + TaskKindFactory; ``` -Only `defineTaskKind` constructs the private brand and installs a module-private erased invocation adapter; -registries store `TaskKindBase` tokens and resolve that adapter rather than invoking methods through an -existential generic type. The public adapter bridge uses `unknown` plus validated records internally, never -exported `any`. The helper preserves all payload witnesses and literal turn capability; omitted `turn` is -returned as `false`. `tx.task`, checkpoint writes and closures infer from that token. `NoExtra` is also applied to the +Only `defineTask` constructs the private brand. Registries store `TaskKindBase`; their private dispatch code +validates durable kind/capability and narrows the registered definition at the invocation boundary, using +`unknown` internally and never exported `any`. Conditional inference derives all payloads directly from +`TaskKind`; there is no second payload-witness or adapter symbol. The helper preserves all payloads and the +literal turn capability; omitted `turn` is returned as `false`. `tx.task`, checkpoint writes and closures infer from that token. `NoExtra` is also applied to the actual inferred task spec type, so visible extra top-level fields in literals, variables and spreads reject. Deep structural exactness, casts and erased fields remain normal TypeScript limits. Wire/plugin RPC boundaries validate strict JSON and optional schemas before calling trusted local APIs. @@ -438,10 +460,12 @@ semantics. The runtime still enforces generic entry, head, edit, ownership and t `background` is independent: speculative manual collapse is `background + turn`; automatic collapse is foreground + turn. -Task-kind replacement while live tasks exist requires the replacement's materialized `turn` to equal every -live task's stored `turn`. Incompatible capability replacement rejects. With no live instance, a future -replacement may change capability for newly created tasks. Terminal history is not rewritten. Payload and -checkpoint compatibility beyond this runtime-checkable property is the replacement author's responsibility. +Task-kind replacement while live tasks exist requires the replacement's materialized `turn` and output +capability to match every live task. An output-capable replacement must declare the same durable output-kind +string as each stored ref; a no-output replacement requires every live instance to omit the ref. Replacement +and open reject a mismatch before invocation. With no live instance, a future replacement may change either +capability for newly created tasks. Terminal history is not rewritten. Payload and checkpoint compatibility +beyond these runtime-checkable properties is the replacement author's responsibility. ## 6. Mutation algebra and storage @@ -467,15 +491,25 @@ type MainMutation = StateMutation | type CommitBatch = | { readonly kind: "main"; readonly writes: readonly MainMutation[] } - | { readonly kind: "scratch"; readonly taskId: Id; readonly writes: readonly StateMutation[] }; + | { readonly kind: "scratch"; readonly taskId: Id; readonly writes: readonly StateMutation[] } + | { readonly kind: "shared"; readonly id: Id; readonly writerTaskId: Id; + readonly writes: readonly StateMutation[] }; -interface CommitReceipt { readonly first: Seq; readonly last: Seq } +interface CommitReceipt { readonly first: Id; readonly last: Id } ``` Each mutation consumes one sequence. `conversation.create`, `entry.append`, `task.create` and -`list.append` carry the ID assigned to that mutation. `task.terminal` atomically retires all scratch for -the task; no separate retirement mutation can be forgotten. A scratch batch contains only scratch-scope -state mutations for exactly its declared task. Main batches contain no scratch address. Empty batches are +`list.append` carry the ID assigned to that mutation. For a newly owned task output, the output ID defaults +to the task ID; task creation and the protected shared `pi.output` base append occur in the same main batch. +Main may write that initial shared base but no other shared state and no task scratch. Later output flushes +use a shared batch containing exactly one protected append for its `id`, authorized by `writerTaskId` whose +current live task stores that ref. A scratch batch contains only task-scope writes for its task. + +`task.terminal` atomically retires private scratch. After the whole prospective main batch, it also retires +each shared output with no live task reference. The kind's closure reads/materializes final output into its +entry/outcome before the kernel determines retirement; the kernel never invents kind-specific data. A new +or same-batch task reference keeps the output live. Terminal task records do not retain it for lifetime +purposes, and a retired ID cannot be resurrected. Empty batches are not passed to storage, do not advance `lastSeq` and publish no event. Storage applies a batch atomically or not at all. It checks expected object IDs against assigned sequences, @@ -487,7 +521,7 @@ that closure on the same handle. ```ts interface Cursor { readonly after?: Id; readonly before?: Id; readonly limit: number } -interface Page { readonly items: readonly T[]; readonly next?: Id; readonly readAt: Seq } +interface Page { readonly items: readonly T[]; readonly next?: Id; readonly readAt: Id } interface ConversationQuery extends Cursor { readonly parent?: Id; @@ -496,7 +530,6 @@ interface ConversationQuery extends Cursor { interface EntryQuery extends Cursor { readonly conversationId: Id; readonly kind?: string; - readonly key?: string; readonly from?: Id; readonly through?: Id; } @@ -509,20 +542,20 @@ interface TaskQuery extends Cursor { } interface ListQuery extends Cursor { readonly at?: Id } interface Storage { - readonly lastSeq: Seq; - claim(call: Call): Promise; - commit(batch: CommitBatch, call: Call): Promise; - getConversations(ids: readonly Id[], call: Call): Promise>; - scanConversations(query: ConversationQuery, call: Call): Promise>; - getEntryKindNames(call: Call): Promise>; - getEntries(ids: readonly Id[], call: Call): Promise>; - scanEntries(query: EntryQuery, call: Call): Promise>; - newestHead(conversationId: Id, at: Id, call: Call): Promise; - getTasks(ids: readonly Id[], call: Call): Promise>; - scanTasks(query: TaskQuery, call: Call): Promise>; - getValue(address: Value, at: Id | undefined, call: Call): Promise | undefined>; - readList(address: List, query: ListQuery, call: Call): Promise>>; - close(call: Call): Promise; // also releases the claim + readonly lastSeq: Id; + claim(ctx: Context): Promise; + commit(batch: CommitBatch, ctx: Context): Promise; + getConversations(ids: readonly Id[], ctx: Context): Promise>; + scanConversations(query: ConversationQuery, ctx: Context): Promise>; + getEntryKindNames(ctx: Context): Promise>; + getEntries(ids: readonly Id[], ctx: Context): Promise>; + scanEntries(query: EntryQuery, ctx: Context): Promise>; + newestHead(conversationId: Id, at: Id, ctx: Context): Promise; + getTasks(ids: readonly Id[], ctx: Context): Promise>; + scanTasks(query: TaskQuery, ctx: Context): Promise>; + getValue(address: Value, at: Id | undefined, ctx: Context): Promise; + readList(address: List, query: ListQuery, ctx: Context): Promise>>; + close(ctx: Context): Promise; // also releases the claim } ``` @@ -549,7 +582,7 @@ normal-versus-abort method, open-reconciliation authority, mark and session phas | mutation | structural current state | structural result | |---|---|---| -| create | absent | pending, no checkpoint/outcome/abort, empty owns | +| create | absent | pending, no checkpoint/outcome/abort, empty owns; output ref/base valid when declared | | running | pending | normal reservation requires terminal dependencies; marked abort reservation ignores dependencies | | checkpoint | running | complete replacement with phase | | own | running | newly created child names this owner; append once in same batch | @@ -559,6 +592,21 @@ normal-versus-abort method, open-reconciliation authority, mark and session phas | orphaned terminal | pending or running | terminal, orphaned outcome required, scratch retired | | any mutation of terminal | terminal | reject | +Task-output creation/reference validation is whole-batch: + +- A no-output kind stores no ref and cannot receive one. +- An output-capable task with no supplied ref stores `{ id: task.id, kind: spec.kind.kind }`; its one + protected initial list element is the same-batch `track(deepClone(spec.initial(input))).flush()` and + contains exactly one `r` base. The tracker owns that clone, never the author's returned object. +- A supplied ref names an existing output with at least one live task reference in the current transaction + view and matches the durable output-kind string. A task invocation may pass its own ref to a task it is + authorized to create; host code may share a live ref through its unrestricted task-creation authority. +- Several creations/terminalizations use the prospective post-batch live reference set. No ref may target a + retired output. +- Every decoded `Op` passes Chord shape/path validation and Pico deep strict-JSON validation. +- A shared batch has one `list.append` to `defineList({type:"shared",id}, "pi.output")`, its element is the + exact nonempty central tracker flush, and `writerTaskId` currently references that output. + Every owned `conversation.create` has exactly one matching `task.own` in the same batch. Reject an unpaired side, ownership of an existing conversation, duplicate ownership or mismatched owner IDs. Conversation ownership and the corresponding task `owns` element are immutable after creation. @@ -605,16 +653,21 @@ The aliases are: ```ts type AnyTaskKind = TaskKindBase; -type PayloadsOf = K extends TaskTypes - ? { input: I; checkpoint: C; result: R; failure: F; aborted: A } - : never; +type PayloadsOf = + K extends TaskKind + ? { input: I; checkpoint: C; result: R; failure: F; aborted: A; output: O } + : never; type InputOf = PayloadsOf["input"]; type CheckpointOf = PayloadsOf["checkpoint"]; type ResultOf = PayloadsOf["result"]; type FailureOf = PayloadsOf["failure"]; type AbortedOf = PayloadsOf["aborted"]; +type OutputOf = PayloadsOf["output"]; type TurnOf = K["turn"]; -type TaskOf = Task, CheckpointOf, ResultOf, FailureOf, AbortedOf>; +type DistributiveOmit = T extends unknown ? Omit : never; +type TaskOf = + DistributiveOmit, CheckpointOf, ResultOf, FailureOf, AbortedOf>, "output"> & + TaskOutputField>; ``` The implementation may use distributive helper aliases to satisfy TypeScript variance without weakening @@ -625,12 +678,14 @@ these public results. None may use `any`. Task-bound checkpoint methods exist on ```ts interface Acceptance { readonly requestId?: string; readonly conversationId: Id; readonly inputId: Id } -interface TaskSpec { +type TaskSpec = { readonly conversationId?: Id; readonly input: I; readonly after?: readonly Id[]; readonly background?: true; -} +} & ([O] extends [never] + ? { readonly output?: never } + : { readonly output?: TaskOutputRef }); interface ConversationCreateSpec { readonly parent?: { readonly conversationId: Id; readonly at: Id }; @@ -638,16 +693,15 @@ interface ConversationCreateSpec { interface BaseTaskTx extends TxReaders { checkpoint(value: C): void; - task>>( + task, OutputOf>>( kind: K, - spec: NoExtra>, S> & { + spec: NoExtra, OutputOf>, S> & { readonly input: ExactJsonInput, S["input"]>; }, ): Id; createConversation(spec: ConversationCreateSpec): Id; value(address: Value): TxValue; list(address: List): TxList; - record(kind: string, conversationId: Id, input: DataEntryInput): Id; accept(conversationId: Id, options: AcceptOptions): Promise; queueInput(conversationId: Id, input: QueuedInput): Promise; write(conversationId: Id, kind: EntryKind, input: EntryInput, requestId?: string): Promise; @@ -665,9 +719,8 @@ automatically records `owner=current task` plus `task.own`; a host transaction c conversation unless an internal owner authority is supplied. Its optional parent must be the current conversation or one in the current task's owned subtree; a task cannot inherit transcript/state from an unrelated tree. A parent may be combined with ownership. -`record` accepts an explicit data-only shape and a kind string; it cannot address protected kind strings. -Typed readers may independently use an `EntryKind` with the same string. `accept`, `queueInput` and `write` -are asynchronous because request-key lookup is +Data-only appends use the capability-restricted typed `entry` method with an `EntryKind`; there is no +parallel untyped `record` API. `accept`, `queueInput` and `write` are asynchronous because request-key lookup is a committed storage read; after that read they synchronously buffer all writes. They do not reenter the line and may operate on a conversation created earlier in the same transaction. This permits one atomic batch to create an owned child, copy state, accept its first input, create its first generation and @@ -685,15 +738,15 @@ task creation does not transfer direct-entry capability to the caller. ```ts interface PublicValue { - get(at: Id | undefined, call: Call): Promise; - set(value: T, call: Call): Promise; - delete(call: Call): Promise; + get(at: Id | undefined, ctx: Context): Promise; + set(value: T, ctx: Context): Promise; + delete(ctx: Context): Promise; } interface PublicList { - read(query: ListQuery, call: Call): Promise>>; - append(value: T, call: Call): Promise; - remove(id: Id, call: Call): Promise; - clear(call: Call): Promise; + read(query: ListQuery, ctx: Context): Promise>>; + append(value: T, ctx: Context): Promise; + remove(id: Id, ctx: Context): Promise; + clear(ctx: Context): Promise; } interface ScratchTx { value(address: Value): TxValue; @@ -703,46 +756,61 @@ interface ScratchReader { value(address: Value): Pick, "get">; list(address: List): Pick, "read">; } +interface TaskOutput { + readonly ref: TaskOutputRef; + read(ctx: Context): Promise; + mutate(mutator: (state: O) => undefined, ctx: Context): Promise; + replace(value: O, ctx: Context): Promise; +} +interface ReadonlyTaskOutput { + readonly ref: TaskOutputRef; + read(ctx: Context): Promise; +} +type RuntimeOutput = [O] extends [never] + ? unknown : { readonly output: TaskOutput }; +type AbortRuntimeOutput = [O] extends [never] + ? unknown : { readonly output: ReadonlyTaskOutput }; +type FinalOutput = [O] extends [never] + ? unknown : { readonly output: O }; interface TaskConversation { readonly id: Id; - snapshot(call: Call): Promise; - accept(options: AcceptOptions, call: Call): Promise; - queueInput(input: QueuedInput, call: Call): Promise; - write(kind: EntryKind, input: EntryInput, requestId: string | undefined, call: Call): Promise; - result(inputId: Id, call: Call): Promise; - waitForInput(inputId: Id, call: Call): Promise; - abortInput(inputId: Id, call: Call): Promise<"aborted" | "already_placed" | "not_found">; + accept(options: AcceptOptions, ctx: Context): Promise; + queueInput(input: QueuedInput, ctx: Context): Promise; + write(kind: EntryKind, input: EntryInput, requestId: string | undefined, ctx: Context): Promise; + result(inputId: Id, ctx: Context): Promise; + waitForInput(inputId: Id, ctx: Context): Promise; + abortInput(inputId: Id, ctx: Context): Promise<"aborted" | "already_placed" | "not_found">; value(address: Value): PublicValue; list(address: List): PublicList; } interface AbortTaskConversation { readonly id: Id; - snapshot(call: Call): Promise; - write(kind: EntryKind, input: EntryInput, requestId: string | undefined, call: Call): Promise; - result(inputId: Id, call: Call): Promise; - waitForInput(inputId: Id, call: Call): Promise; + write(kind: EntryKind, input: EntryInput, requestId: string | undefined, ctx: Context): Promise; + result(inputId: Id, ctx: Context): Promise; + waitForInput(inputId: Id, ctx: Context): Promise; value(address: Value): PublicValue; list(address: List): PublicList; } -interface BaseTaskRuntime { +interface BaseTaskRuntime { readonly taskId: Id; - commit(build: (tx: BaseTaskTx, current: RunningTask) => T | Promise, call: Call): Promise; - scratch(build: (tx: ScratchTx) => T | Promise, call: Call): Promise; - conversation(id: Id, call: Call): Promise; - waitForTask(id: Id, call: Call): Promise; - abortTask(id: Id, call: Call): Promise<"marked" | "terminal">; + commit(build: (tx: BaseTaskTx, current: RunningTask) => T | Promise, ctx: Context): Promise; + scratch(build: (tx: ScratchTx) => T | Promise, ctx: Context): Promise; + conversation(id: Id, ctx: Context): Promise; + waitForTask(id: Id, ctx: Context): Promise; + abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">; now(): number; - sleep(untilMs: number, call: Call): Promise; + sleep(untilMs: number, ctx: Context): Promise; } -interface TurnTaskRuntime - extends Omit, "commit"> { - commit(build: (tx: TurnTaskTx, current: RunningTask) => T | Promise, call: Call): Promise; +interface TurnTaskRuntime + extends Omit, "commit"> { + commit(build: (tx: TurnTaskTx, current: RunningTask) => T | Promise, ctx: Context): Promise; } -type RuntimeFor = - T extends true ? TurnTaskRuntime : BaseTaskRuntime; +type RuntimeFor = + (T extends true ? TurnTaskRuntime : BaseTaskRuntime) & RuntimeOutput; ``` `runtime.conversation`, `waitForTask` and `abortTask` accept only targets in the current task's ownership @@ -752,63 +820,82 @@ normal lifecycle validation. `TaskConversation` has no raw commit, direct entry, drive, close, shutdown, delete or host-wide registry methods. Task operations capture the expected invocation when the runtime/handle is constructed. Every -supplied Call must contain that exact current invocation object. An absent, foreign or stale identity +supplied Context must contain that exact current invocation object. An absent, foreign or stale identity rejects; task methods never fall back to host authority. +Exactly one authoritative Chord tracker is hydrated per live output ID. `read` snapshots it without exposing +a retained proxy. `mutate` invokes its callback synchronously on the commit line against the central proxy, +flushes one nonempty delta, and commits that delta in one shared batch before resolving. Requiring an +`undefined` return rejects async callbacks at compile time. The callback cannot retain the proxy, call +another runtime method, or mutate after returning. Objects inserted through the proxy become tracker-owned; +the author must not retain and later mutate their raw references. If the callback throws, returns a thenable, or causes Chord/strict-JSON validation to fail, nothing commits or +publishes and the session fail-stops before releasing the line. The poisoned in-memory tracker is never used +again; reopen reconstructs it from durable deltas. This avoids cloning the growing output before every delta. +`replace` uses the same tracker's `state` setter and commit path. Concurrent task writes serialize in global +commit order. Any persistence failure after tracker mutation also fail-stops. No observer sees uncommitted +tracker state. Runtime checks the ref's durable kind string, not JavaScript token +identity or structural TypeScript compatibility. + ### 7.4 Terminal and abort transactions ```ts interface TerminalTx extends BaseTaskTx {} interface TurnTerminalTx extends TurnTaskTx {} -type FinalTx = - T extends true ? TurnTerminalTx : TerminalTx; +type FinalTx = + (T extends true ? TurnTerminalTx : TerminalTx) & FinalOutput; type TerminalClosure = - (tx: FinalTx, current: RunningTask) => + R extends JsonValue, F extends JsonValue, + O extends object, T extends false | true> = + (tx: FinalTx, current: RunningTask) => Completion | Promise>; interface AbortTx extends TxReaders { checkpoint(value: C): void; value(address: Value): TxValue; list(address: List): TxList; - record(kind: string, conversationId: Id, input: DataEntryInput): Id; write(conversationId: Id, kind: EntryKind, input: EntryInput, requestId?: string): Promise; markTask(id: Id): Promise<"marked" | "terminal">; } interface TurnAbortTx extends AbortTx { entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; } -type AbortFinalTx = T extends true ? TurnAbortTx : AbortTx; +type AbortFinalTx = + (T extends true ? TurnAbortTx : AbortTx) & FinalOutput; type AbortClosure = - (tx: AbortFinalTx, current: RunningTask) => A | Promise; + A extends JsonValue, O extends object, T extends false | true> = + (tx: AbortFinalTx, current: RunningTask) => A | Promise; -interface BaseAbortRuntime { +interface BaseAbortRuntime { readonly taskId: Id; - commit(build: (tx: AbortTx, current: RunningTask) => T | Promise, call: Call): Promise; - scratch(read: (scratch: ScratchReader) => T | Promise, call: Call): Promise; - conversation(id: Id, call: Call): Promise; - waitForTask(id: Id, call: Call): Promise; - abortTask(id: Id, call: Call): Promise<"marked" | "terminal">; + commit(build: (tx: AbortTx, current: RunningTask) => T | Promise, ctx: Context): Promise; + scratch(read: (scratch: ScratchReader) => T | Promise, ctx: Context): Promise; + conversation(id: Id, ctx: Context): Promise; + waitForTask(id: Id, ctx: Context): Promise; + abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">; now(): number; } -interface TurnAbortRuntime - extends Omit, "commit"> { - commit(build: (tx: TurnAbortTx, current: RunningTask) => T | Promise, call: Call): Promise; +interface TurnAbortRuntime + extends Omit, "commit"> { + commit(build: (tx: TurnAbortTx, current: RunningTask) => T | Promise, ctx: Context): Promise; } -type AbortRuntimeFor = - T extends true ? TurnAbortRuntime : BaseAbortRuntime; +type AbortRuntimeFor = + (T extends true ? TurnAbortRuntime : BaseAbortRuntime) & AbortRuntimeOutput; ``` Abort runtime exposes repeated restricted commits with the same surface as `AbortTx`, plus scratch reads but no scratch writes. It cannot create tasks/conversations, delete conversations, call `accept`, queue steer/followUp/nextRun, or drive. `write` in abort is restricted to passive `mode:"write"`; it may append immediately only when safe and otherwise remains queued. Turn abort keeps direct entry authority so a tool -can atomically publish its aborted tool result. Checkpoint replacement is allowed during lengthy fresh -cleanup; a crash reruns abort from the newest checkpoint because the durable mark remains. +can atomically publish its aborted tool result. Marking immediately revokes the normal invocation's +`TaskOutput` mutation authority before signaling its context. Fresh abort receives only +`ReadonlyTaskOutput`: `ref` and `read`, never `mutate`/`replace`. Its final closure receives a detached +`tx.output` snapshot captured on the line so it can persist final output-derived data in the same main batch +as terminalization and possible last-reference retirement. Checkpoint replacement is allowed during lengthy +fresh cleanup; a crash reruns abort from the newest checkpoint because the durable mark remains. Terminal closures run on the line after all effects and producer joins. They may await transaction storage reads and mint same-batch IDs, but may not perform external effects, hooks, sleeps, waits or nested runtime @@ -823,30 +910,30 @@ Host transactions are not task invocations. They may append model-affecting entr ```ts interface HostTx extends TxReaders { - task> & { readonly conversationId: Id }>( + task, OutputOf> & { readonly conversationId: Id }>( kind: K, - spec: NoExtra> & { readonly conversationId: Id }, S> & { + spec: NoExtra, OutputOf> & { readonly conversationId: Id }, S> & { readonly input: ExactJsonInput, S["input"]>; }, ): Id; createConversation(spec: ConversationCreateSpec): Id; value(address: Value): TxValue; list(address: List): TxList; - record(kind: string, conversationId: Id, input: DataEntryInput): Id; entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; accept(conversationId: Id, options: AcceptOptions): Promise; queueInput(conversationId: Id, input: QueuedInput): Promise; write(conversationId: Id, kind: EntryKind, input: EntryInput, requestId?: string): Promise; } -interface ConversationTx extends Omit { - task>, "conversationId">>( +interface ConversationTx extends Omit { + task, OutputOf>, "conversationId">>( kind: K, - spec: NoExtra>, "conversationId">, S> & { + spec: NoExtra, OutputOf>, "conversationId">, S> & { readonly input: ExactJsonInput, S["input"]>; }, ): Id; - record(kind: string, input: DataEntryInput): Id; entry(kind: EntryKind, input: EntryInput): Id; accept(options: AcceptOptions): Promise; queueInput(input: QueuedInput): Promise; @@ -855,34 +942,33 @@ interface ConversationTx extends Omit; - commit(build: (tx: ConversationTx) => T | Promise, call: Call): Promise; - accept(options: AcceptOptions, call: Call): Promise; - queueInput(input: QueuedInput, call: Call): Promise; - write(kind: EntryKind, input: EntryInput, requestId: string | undefined, call: Call): Promise; - result(inputId: Id, call: Call): Promise; - waitForInput(inputId: Id, call: Call): Promise; - abortInput(inputId: Id, call: Call): Promise<"aborted" | "already_placed" | "not_found">; - drive(call: Call): Promise<"idle" | "closed">; - abort(call: Call): Promise; - fork(options: { readonly at: Id | "start"; readonly abort?: boolean }, call: Call): Promise; - collapse(options: { readonly instructions?: string } | undefined, call: Call): Promise; - reset(options: { readonly handoff?: string } | undefined, call: Call): Promise; + commit(build: (tx: ConversationTx) => T | Promise, ctx: Context): Promise; + accept(options: AcceptOptions, ctx: Context): Promise; + queueInput(input: QueuedInput, ctx: Context): Promise; + write(kind: EntryKind, input: EntryInput, requestId: string | undefined, ctx: Context): Promise; + result(inputId: Id, ctx: Context): Promise; + waitForInput(inputId: Id, ctx: Context): Promise; + abortInput(inputId: Id, ctx: Context): Promise<"aborted" | "already_placed" | "not_found">; + drive(ctx: Context): Promise<"idle" | "closed">; + abort(ctx: Context): Promise; + fork(options: { readonly at: Id | "start"; readonly abort?: boolean }, ctx: Context): Promise; + collapse(options: { readonly instructions?: string } | undefined, ctx: Context): Promise; + reset(options: { readonly handoff?: string } | undefined, ctx: Context): Promise; value(address: Value): PublicValue; list(address: List): PublicList; } interface Harness { - root(call: Call): Promise; - conversation(id: Id, call: Call): Promise; - commit(build: (tx: HostTx) => T | Promise, call: Call): Promise; - acceptance(requestId: string, call: Call): Promise; - getEntry(id: Id, call: Call): Promise; - getTask(id: Id, call: Call): Promise; - abortTask(id: Id, call: Call): Promise<"marked" | "terminal">; - drive(call: Call): Promise<"idle" | "closed">; - close(call: Call): Promise; - shutdown(call: Call): Promise; + root(ctx: Context): Promise; + conversation(id: Id, ctx: Context): Promise; + commit(build: (tx: HostTx) => T | Promise, ctx: Context): Promise; + acceptance(requestId: string, ctx: Context): Promise; + getEntry(id: Id, ctx: Context): Promise; + getTask(id: Id, ctx: Context): Promise; + abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">; + drive(ctx: Context): Promise<"idle" | "closed">; + close(ctx: Context): Promise; + shutdown(ctx: Context): Promise; } ``` @@ -898,7 +984,6 @@ type UserInput = string | readonly (TextContent | ImageContent)[]; interface StoredEntryDraft { readonly kind: string; - readonly key?: string; readonly data?: JsonValue; readonly model?: readonly Message[]; readonly head?: Id | "self"; @@ -959,9 +1044,10 @@ The foundation does not guess a provider generation payload. Input placement rec ```ts interface InternalBoundaryTx extends TxReaders { - task> & { readonly conversationId: Id }>( + task, OutputOf> & { readonly conversationId: Id }>( kind: K, - spec: NoExtra> & { readonly conversationId: Id }, S> & { + spec: NoExtra, OutputOf> & { readonly conversationId: Id }, S> & { readonly input: ExactJsonInput, S["input"]>; }, ): Id; @@ -1086,7 +1172,7 @@ committed checkpoint/mark changes; it never receives only the method's older sna Caller cancellation is checked while waiting to enter the line and again before the builder begins. Once the builder is admitted, caller cancellation cannot abandon builder completion or persistence. Storage -receives an internal non-abandoning Call that preserves telemetry but not the caller's abort signal. Task +receives an internal non-abandoning Context that preserves telemetry but not the caller's abort signal. Task invocation identity, lifecycle and durable mark are still revalidated on the line before invoking its builder. @@ -1110,7 +1196,7 @@ Publication never precedes persistence. Every index observes a full batch before so terminal task + successor has no visible idle gap. Private invocation identity contains task ID, method (`execute`, `recover`, `abort`) and a unique object. -The driver installs that exact object in a derived Call and binds every task runtime/handle to it. Runtime +The driver installs that exact object in a derived Context and binds every task runtime/handle to it. Runtime admission compares object identity with the invocation slot. Metadata/RPC transport never confers authority. Authorization table: @@ -1213,7 +1299,7 @@ revoke execute/recover main and scratch writes immediately leave line; signal old invocation wait for method, effects and owned progress producers to return on line discard any normal terminal closure and release old invocation -reserve fresh abort with new identity/controller/Call +reserve fresh abort with new identity/controller/Context abort may perform restricted checkpointed cleanup abort returns closure apply passive/direct turn writes + aborted outcome + scratch retirement atomically @@ -1254,16 +1340,19 @@ returned as typed outcomes by their kinds. Scratch uses the state vocabulary under `{type:"task", taskId}`. One scratch transaction writes one live task only. It has value/list get/set/delete/append/remove/clear; reads are async, writes buffered. -Scratch is never rewindable, inherited or part of context. +Scratch is never rewindable, inherited or part of context. Task scope is private scratch. Shared scope is +separate observable ephemeral state: it may be referenced by several live tasks and is not retained merely +to preserve one task's unrelated scratch. Execute/recover may read and write its own scratch while unmarked. Abort may read but not write scratch; it persists cleanup progress in the task checkpoint or permitted main state. A crash before terminal outcome retains scratch. `task.terminal` retires it atomically. Every later scratch write rejects, even if a sidecar unlink failed. -A retry under one task ID clears attempt-specific lists before new output. Generation stores compact -assistant frames, not cumulative provider snapshots. Generic output packages store compact append/replace/ -truncate operations, not growing whole-value copies. +A retry under one task ID resets attempt-specific live state through `runtime.output.replace` before new +output. Generation stores no second durable frame stream in task scratch: its task output is the sole +partial assistant representation. Chord persists compact append/replace/truncate deltas, not growing +whole-value copies. Harness-owned producers own every pending scratch promise, cancel on early exit, suppress only expected mark/close rejection, report persistence faults and join before the invocation may finalize. Iterator exit @@ -1298,19 +1387,19 @@ interface OpenInspection { readonly unknownEntryKinds: readonly string[]; } interface MutableRegistry { - get(kind: string, call: Call): Promise; - register(definition: T, call: Call): Promise; - replace(definition: T, call: Call): Promise; - remove(kind: string, call: Call): Promise; + get(kind: string, ctx: Context): Promise; + register(definition: T, ctx: Context): Promise; + replace(definition: T, ctx: Context): Promise; + remove(kind: string, ctx: Context): Promise; } interface Harness { readonly kinds: CoreTaskKinds; readonly entryKinds: MutableRegistry; readonly taskKinds: MutableRegistry; - inspect(call: Call): Promise; + inspect(ctx: Context): Promise; } interface HarnessFactory { - open(storage: Storage, options: HarnessOpenOptions, call: Call): Promise; + open(storage: Storage, options: HarnessOpenOptions, ctx: Context): Promise; } ``` @@ -1389,7 +1478,8 @@ attempt, retry wait, deferred handle and any request-specific validation data ne request snapshot is immutable. `before_request` may transform a private messages-only request; actual offered tools after transformation are the validation basis and must be recoverable when needed. -Assistant frames live in scratch. Retry and deferred polling loop cooperatively under one stable task ID. +Assistant frames mutate the generation task output; scratch contains only unrelated private recovery data. +Retry and deferred polling loop cooperatively under one stable task ID. `on_yield` receives the prospective assistant message/draft plus stable task ID, not an entry ID that does not exist. The returned terminal closure atomically appends assistant/usage, resolves or transfers inputs, and creates tools/post_tools/continuation. @@ -1431,6 +1521,55 @@ is lost unless durable policy explicitly permits rerun. Recurring schedules use checkpoint; no backlog is inferred after downtime. Terminal notices are passive writes and work in either notification-before-completion order. +#### Shared task output + +Task output is the only live-output mechanism; there is no separate preview subsystem. The exact declaration, +reference and facade types are in sections 5 and 7. A task kind declares `TaskOutputSpec`. Creation either +uses a compatible `TaskOutputRef` or synchronously computes a new base with `initial(input)`. Runtime +compatibility compares `TaskOutputKind.kind`, not token object identity or structural TypeScript compatibility. + +Output is a protected list at a fixed shared address: + +```ts +type TaskOutputDelta = readonly Op[]; +const taskOutputDeltas = defineList( + { type: "shared", id: taskOutputId }, + "pi.output", +); +``` + +Persist decoded Chord `Op[]`, not `WireOp[]`, in v1. The first list element is exactly the initial tracker +flush and starts with one `r` base. Every later element is exactly one nonempty flush from the single central +tracker, preserved as one atomic append. Replay folds the elements with `applyImmutable()` into a mutable +strict-JSON tree and then hydrates exactly one authoritative tracker. Tasks receive controlled +`TaskOutput`, never trackers, proxies, raw protected addresses, or independent mutable replicas. After +hydrating a tracker from replayed state, the kernel consumes its synthetic initial `flush()` locally to +establish the baseline; it does not persist that synthetic base. The first later mutation therefore remains +incremental unless the author explicitly calls `replace`. + +Several live tasks may reference and write the same task output. There is no mutable owner field or transfer +event. Retirement derives from prospective post-batch live `Task.output` references. The output remains while +at least one exists, retires atomically after the final reference terminalizes, and can never be resurrected. +Private task scratch remains independent. + +Tool timeout uses one stable task output: tool T creates O and background job B is created with T's +`TaskOutputRef`. B writes progress directly. T's terminal closure rereads B on the line. If B is live, T +materializes the immutable "continues as B" result and terminalizes while B retains O. If B already won, T +takes the normal completed path. B materializes final O before terminalizing as the last reference. Never +trust stale `Promise.race` state. Marking either task revokes that invocation's mutable output facade before +its signal; fresh abort can only read. + +Generation uses the same mechanism with an object wrapper around its `AssistantMessage`; frames mutate the +central task output and Chord emits structured deltas for text, thinking, signatures, tool calls/partial +arguments, metadata, usage and diagnostics. Generation materializes the immutable assistant entry before +its final output reference retires. Tool-specific APIs, if useful, are convenience facades over +`TaskOutput`, not a second storage or writer abstraction. + +Each protected output-list append mechanically emits one `task_output` event keyed by stable task-output ID +and carrying that exact decoded delta. `ConversationView.taskOutputs` folds deltas with `applyImmutable()`. +In-process fan-out gives each reducer immutable application rather than sharing a mutable decoded batch. If +wire path encoding is added later, each independently hydrated stream owns one encoder/decoder. + ### 14.4 System sections Pico sends pi-ai `{messages}` only; top-level `systemPrompt` and `tools` are absent. A typed @@ -1447,7 +1586,7 @@ retries managed-state staleness, and commits system entry plus request cutoff at ### 14.5 Hooks and external events Hooks are the preferred extension point inside built-in operations. They run outside the line, receive and -forward Call, may run again after crash, and return typed decisions that the built-in commits. A hook may +forward Context, may run again after crash, and return typed decisions that the built-in commits. A hook may start and await ordinary durable side work while the built-in retains exchange ownership. Approval/question policy and durable answer reuse belong to workspace/plugins. Exact namespaced hook scratch access remains a gated API decision. @@ -1460,24 +1599,28 @@ provider request; steering affects the next safe request boundary. ### 15.1 Watch -The ready watch foundation deliberately excludes previews and `task_output`; their source/API is gated. - ```ts interface WatchedValue { readonly address: Address; readonly value?: JsonValue } +interface WatchedTaskOutput { + readonly id: Id; + readonly kind: string; + readonly value: JsonObject | readonly JsonValue[]; +} interface ConversationView { readonly conversation: Conversation; readonly tail: number; readonly entries: readonly Entry[]; readonly context: readonly Id[]; readonly tasks: readonly Task[]; + readonly taskOutputs: readonly WatchedTaskOutput[]; readonly inbox: readonly Element[]; readonly values: readonly WatchedValue[]; - readonly readAt: Seq; + readonly readAt: Id; } interface SessionView { readonly conversations: readonly Conversation[]; readonly values: readonly WatchedValue[]; - readonly readAt: Seq; + readonly readAt: Id; } type InboxOp = | { readonly type: "append"; readonly item: Element } @@ -1488,6 +1631,8 @@ type ConversationEvent = | { readonly type: "task_start"; readonly task: Task } | { readonly type: "task_update"; readonly task: Task; readonly previous: Task } | { readonly type: "task_end"; readonly task: Task & { readonly status: "terminal" } } + | { readonly type: "task_output"; readonly id: Id; readonly kind: string; + readonly delta: readonly Op[] } | { readonly type: "value"; readonly value: WatchedValue } | { readonly type: "inbox"; readonly ops: readonly InboxOp[] } | { readonly type: "context"; readonly ids: readonly Id[] }; @@ -1495,14 +1640,13 @@ type SessionEvent = | { readonly type: "conversation"; readonly conversation: Conversation; readonly change: "created" } | { readonly type: "value"; readonly value: WatchedValue }; interface CommitEnvelope { - readonly first: Seq; - readonly last: Seq; + readonly first: Id; + readonly last: Id; readonly events: readonly E[]; } type WatchDelivery = | { readonly type: "commit"; readonly commit: CommitEnvelope } - | { readonly type: "lag" } - | { readonly type: "closed" }; + | { readonly type: "closed"; readonly reason: "overflow" | "session" }; interface WatchOptions { readonly capacity?: number; readonly onError?: (error: unknown) => void; @@ -1517,7 +1661,6 @@ interface SessionWatchOptions extends WatchOptions { interface WatchHandle { readonly view: V; start(listener: (delivery: WatchDelivery) => void): void; - resnapshot(call: Call): Promise; unsubscribe(): void; } declare function applyConversationCommit( @@ -1532,18 +1675,18 @@ interface WatchService { watchConversation( conversationId: Id, options: ConversationWatchOptions, - call: Call, + ctx: Context, ): Promise>; watchSession( options: SessionWatchOptions, - call: Call, + ctx: Context, ): Promise>; } ``` `tail` is an integer from 0 through 10,000. A conversation capture contains its last `tail` logical -fork-visible entries, live tasks directly belonging to that conversation, current inbox and exactly the -requested values. Conversation watch values may be session addresses or addresses bound to that +fork-visible entries, live tasks directly belonging to that conversation, every distinct task output they +reference, current inbox and exactly the requested values. Conversation watch values may be session addresses or addresses bound to that conversation; foreign-conversation/task addresses reject. A session watch accepts session addresses only. A later source-conversation write emits no event for an existing fork. The pure reducer uses `view.tail` to retain only the newest logical transcript entries. A session capture contains all current conversations and @@ -1557,27 +1700,28 @@ subscription's filters is not delivered and consumes no buffer capacity, while t `handle.view.readAt` still advances. Emit a full `context` event whenever the derived context-ID array changes, including ordinary entry appends. -When capacity would be exceeded, discard queued commit deliveries, emit one `lag` delivery, and pause that -subscription. The in-process `handle.view` continues folding every commit while delivery is paused, but the -consumer must not use it as a resumption barrier. `resnapshot` enters the line, captures a fresh view and -sequence barrier, discards deliveries -through that barrier, resumes after it, mutates `handle.view` to the new view before resolving, and returns -that same view. Calling it in response to a listener delivery does not run on the commit line and cannot -deadlock. `unsubscribe` is idempotent, discards buffered deliveries and prevents future callbacks. If a listener throws, unsubscribe only that watch and invoke `options.onError` outside the line when -provided; never fault the session. An error thrown by `onError` is ignored by Pico. Close emits `closed` once unless unsubscribed. +When capacity would be exceeded, discard queued commits and close that watch with reason `overflow`; it +never pauses or maintains a hidden advancing view. The consumer reconnects by opening a new watch, whose +atomic capture is the new authoritative state and sequence barrier. There is no resnapshot/resumption API. +`unsubscribe` is idempotent, discards buffered deliveries and prevents future callbacks. If a listener +throws, close only that watch without another listener call and invoke `options.onError` outside the line; +never fault the session. An error thrown by `onError` is ignored. Session close delivers `closed/session` once +unless unsubscribed. `task_start` is task creation at pending. Reservation is `task_update` to running. Checkpoint/abort/ownership -changes are task updates. Terminal is `task_end` and always includes an outcome. Reducers are kind-free and -pure. Conversation values are only the explicitly requested addresses; session values are explicitly +changes are task updates. Terminal is `task_end` and always includes an outcome. Each protected shared append +emits one `task_output`; the reducer applies its delta with `applyImmutable()`. Envelope order places output +before a same-batch final `task_end`, after which the reducer removes an output with no remaining live task +reference. Sharing requires no separate event. Reducers are kind-free and pure. Conversation values are only the explicitly requested addresses; session values are explicitly configured by the session watch caller in the final Harness API. ### 15.2 Backend construction and durability ```ts interface MemoryStorageFactory { create(): Storage } -interface JsonlStorageFactory { open(path: string, call: Call): Promise } +interface JsonlStorageFactory { open(path: string, ctx: Context): Promise } interface SqliteStorageFactory { - open(path: string, options: { readonly session: string }, call: Call): Promise; + open(path: string, options: { readonly session: string }, ctx: Context): Promise; } ``` @@ -1585,26 +1729,32 @@ JSONL/SQLite `open` acquires the cross-process session lock and finishes replay `claim` separately binds one driver to the returned object. `close` releases both and is idempotent. Memory has no cross-process lock but enforces one claim. -Every JSONL main and live-scratch commit is fsynced before in-memory publication or commit resolution. -Creating the main file, a scratch file or a containing directory requires fsync of the created file and -each affected parent directory before publication. Scratch unlink need not be directory-durable because -the fsynced terminal main record is authoritative. No weaker durability mode exists in v1. +Every JSONL main, live-scratch and live-shared commit is fsynced before in-memory publication or commit +resolution. Creating the main file, a sidecar or a containing directory requires fsync of the created file +and each affected parent directory before publication. Sidecar unlink need not be directory-durable because +the fsynced main record proving retirement is authoritative. No weaker durability mode exists in v1. -JSONL uses one main file and one file per live task scope, all sharing the sequence. Each newline-terminated -record is: +JSONL uses one main file, one file per live task scope, and an optional file per live shared task output that +has post-creation deltas, all sharing the sequence. Task creation and its initial output base exist only in +the atomic main record; creating an output does not require a shared sidecar. Each newline-terminated record +is: ```ts interface JsonlRecord { - readonly first: Seq; - readonly last: Seq; + readonly first: Id; + readonly last: Id; readonly batch: CommitBatch; } ``` `last - first + 1` equals `batch.writes.length`; each create/element ID matches its derived sequence. -Replay main first, then scratch only for surviving live tasks. Retired scratch is ignored even if malformed. -Retained ranges increase within each file and never overlap across retained files; gaps are valid. Validate -batch kind against file, scratch task/address references, object IDs and lifecycle/scope structure. +Replay main first, including each live output's initial protected base append. From its final live tasks, +derive the live scratch IDs and distinct live task-output refs; then replay only their existing sidecars as +later deltas. A missing shared sidecar means no post-creation write. Retired scratch/shared files are ignored +even if malformed, so a stale file cannot resurrect state. Every retained shared record contains only a +matching `kind:"shared"` batch; replay folds it after the base already recovered from main. Retained ranges +increase within each file and never overlap across retained files; gaps are valid. Validate batch kind +against file, task/address references, object IDs, output refs and lifecycle/scope structure. `lastSeq` is the maximum complete retained endpoint including clear/remove. A torn suffix is bytes after the final newline. Truncate those bytes and fsync before append. A @@ -1612,8 +1762,8 @@ newline-terminated record that is malformed JSON or structurally invalid fails o record before applying it to memory. SQLite uses indexed conversations, full immutable entry facets, current tasks, values plus rewindable -versions, list elements/clear markers, scratch and commit boundaries. Index task status/abort/conversation/ -kind and entry conversation/head/kind/key. Terminal transaction deletes scratch rows atomically. Reopen +versions, list elements/clear markers, task scratch, shared task-output refs/deltas and commit boundaries. Index task status/abort/conversation/ +kind and entry conversation/head/kind. Terminal transaction deletes scratch rows atomically. Reopen loads live tasks and named owner records, not terminal history into residency. Version mismatch rejects. Use durable SQLite transactions with `PRAGMA synchronous=FULL` or an explicitly documented equivalent at least as strong. Journal mode is backend-private if locking, atomic scratch retirement and conformance hold. @@ -1634,7 +1784,7 @@ Every race is tested in both orders with fake clocks/effects and storage barrier - Closure throws: no closure writes/outcome; session faults. - Terminal persistence uncertain: fail-stop; closure not retried on handle. - Pending task marked before reservation: execute never runs; fresh abort does. -- Repeated abort mark while abort runs: abort Call remains active. +- Repeated abort mark while abort runs: abort Context remains active. - Close versus commit: admitted earlier commit finishes; later mutation rejects; completion messages admitted. - Shutdown versus accept: accept commits before mark batch or rejects; queues preserved. - Shutdown crash before child cleanup: reopen abort marks child tasks only and preserves queues. @@ -1654,6 +1804,8 @@ Every race is tested in both orders with fake clocks/effects and storage barrier - Overflow chain: collapse and replacement atomic; no invocation waits on collapse. - Competing summary head: stale failure; intervening edits do not stale. - Watch capture versus commit: base includes commit or stream delivers it, never gap/duplicate. +- Watch overflow/listener throw: that watch closes; a newly opened watch captures one fresh authoritative + base without a resumption protocol. - JSONL main 100/live scratch 150: reopen lastSeq 150. - Terminal 151/unlink failure: ignore scratch even malformed; lastSeq 151. - JSONL torn final suffix versus malformed complete line: truncate only former. @@ -1662,6 +1814,14 @@ Every race is tested in both orders with fake clocks/effects and storage barrier queues retained, root drive later runs their fresh abort. - Capability replacement with live mismatch: reject before invocation. - Cast non-turn runtime to turn and direct append: reject, no writes. +- New task plus initial task-output base: both persist or neither; `initial` is not called for a supplied ref. +- Two tasks mutate one task output: central tracker commits deltas in line order without a lost update. +- Mark versus task-output mutation: commit before mark wins; otherwise mutation rejects before callback. +- Last reference terminal versus same-batch child sharing: prospective references retain output; otherwise + final materialization and retirement are atomic. +- Task-output delta versus watch capture/overflow: base or exact ordered delta, never gap/duplicate/mutable + alias. +- Shared sidecar after terminal/unlink failure: reopen ignores it; no resurrection. ## 17. Ready implementation packages @@ -1674,14 +1834,16 @@ memory storage; it need not expose incomplete production Harness behavior. Prerequisites: none. Deliver: -- strict JSON, ID, entry, conversation, task/checkpoint/outcome and address types; -- `EntryKind`, `TaskKind`, payload extractors and literal turn capability; -- capability-selected runtime/closure declaration types, with method bodies deferred; +- strict JSON, ID, entry, conversation, task/checkpoint/outcome and address types, including non-rewindable + shared scope identity; +- `EntryKind`, `TaskKind`, `TaskOutputKind`, typed/erased output refs, payload extractors and literal turn + capability; +- capability-selected runtime/output/closure declaration types, with method bodies deferred; - no imports from existing harness implementation. Accept: -- compile tests for exact task input, full checkpoint with phase, kind-specific outcomes and literal turn - runtime/terminal/abort surfaces; +- compile tests for exact task input, full checkpoint with phase, kind-specific outcomes, literal turn and + no-output/owned-output/compatible-shared-output surfaces; - non-turn has no direct entry; turn does; turn abort does; - incompatible checkpoint shape rejects; structurally identical shape is documented assignable; - Pico declarations introduce and explicitly spell no `any`; imported pi-ai types are exempt; @@ -1692,7 +1854,7 @@ Accept: Prerequisites: WP1. Deliver: -- exact main/scratch mutations and storage interface; +- exact main/scratch/shared mutations and storage interface; - immutable snapshotting; - sequence allocation/validation, atomic memory commit and empty-batch behavior; - conversations, entries, task lifecycle/checkpoint/ownership/abort/terminal retirement; @@ -1702,7 +1864,7 @@ Deliver: Accept: - hand-built batch conformance for every query and mutation; - rejected batch leaves state/lastSeq unchanged; -- terminal mutation makes scratch unreadable atomically; +- terminal mutation makes scratch unreadable atomically and retires unreferenced shared output; - fork/history/deep-cap tests; committed reads return no mutable aliases; - focused test and repository check green. @@ -1715,14 +1877,14 @@ Deliver: - transaction overlay, synchronous buffered writes, async committed reads; - ID minting/rollback; rewindable-before-entry rule; - base/turn/abort transaction capability construction and runtime validation; -- protected namespace/kind checks; +- atomic task-output base creation, compatible reference admission and protected namespace/kind checks; - no public accept/write/runtime handles yet. Accept: - concurrent commits serialize; async read holds line; - callback failure consumes no ID; - same-batch references validate from overlay; -- non-turn cast direct entry rejects; data-only record works; +- non-turn cast direct entry rejects; typed data-only entry works; - managed/protected generic writes reject; - runtime strict-JSON validation rejects invalid entry/task/state payloads before persistence, including payloads carried by imported pi-ai types; @@ -1768,6 +1930,7 @@ Prerequisites: WP5. Deliver: - pending creation and durable running reservation; +- one central Chord tracker per live task output plus controlled read/mutate/replace facade; - capability-specific execute/recover runtime and normal terminal closures; - invocation identity/slot; atomic completed/failed outcome plus scratch retirement; - scratch transactions and normal closure seal/disposition behavior; @@ -1778,7 +1941,8 @@ Accept: - repeated recovery from newer checkpoint under stable ID; - one closure outcome, same-batch minted result ID, throwing closure rollback/fault; - normal closure commits exactly once when still authorized; WP8 adds mark/close/fault discard races; -- scratch crash retention/terminal retirement/post-retirement rejection. +- scratch crash retention/terminal retirement/post-retirement rejection; +- shared output concurrent writes, final closure read/materialization and reference-derived retirement. ### WP7 — Dependencies, serving and waits @@ -1803,7 +1967,7 @@ Accept: Prerequisites: WP7. Deliver: -- mark/revoke/signal/join/fresh-abort sequence and abort runtime construction; +- mark/revoke (including mutable task output)/signal/join/fresh-abort sequence and abort runtime construction; - restricted repeated abort commits, turn abort closure, and mark/close/fault normal-closure discard; - conversation abort operation, completion wait and queue policy; - close, shutdown and fault phase machines with shared repeated completions; @@ -1813,7 +1977,7 @@ Deliver: Accept: - every cancellation/lifecycle row in section 16 applicable to memory; -- fresh abort checkpoint/reopen; repeated mark does not cancel abort; +- fresh abort checkpoint/reopen with read-only task output; repeated mark does not cancel abort; - passive abort writes but no future work; tool-like direct abort result for turn kind; - close writes no outcomes; shutdown marks all and preserves queues; fault preserves unfinished state; - initialization starts nothing; every missing live kind orphans, retires scratch and marks descendants; @@ -1823,8 +1987,9 @@ Accept: Prerequisites: WP8. -Deliver the kind-free commit-derived view/event reducer, atomic capture/subscription, bounded lag and -resnapshot. Preview and task-output delivery are excluded until their gated API is settled. +Deliver the kind-free commit-derived view/event reducer, including `taskOutputs`/`task_output`, atomic +capture/subscription, bounded overflow closure and reconnect-by-new-capture. There is no lag/resnapshot or +preview subsystem. Accept the watch races and task lifecycle event meanings in sections 15–16. @@ -1833,13 +1998,14 @@ Accept the watch races and task lifecycle event meanings in sections 15–16. Prerequisites: WP8; WP9 only for cross-backend watch tests. Deliver the backend and all recovery rules in section 15. Run the same storage/admission/task conformance -stream as memory, including real process-kill recovery for running/checkpoint/scratch/terminal cases. +stream as memory, including real process-kill recovery for running/checkpoint/scratch/shared-output/terminal +cases and no-resurrection sidecar cleanup. ### WP11 — SQLite backend Prerequisites: WP8; WP9 only for cross-backend watch tests. -Deliver indexed persistence/residency and conformance in section 15. Update the session-backend package only +Deliver indexed persistence/residency, shared task-output persistence/retirement and conformance in section 15. Update the session-backend package only through a reviewed adapter boundary; no import from existing harness runtime/session implementations. ## 18. Gated packages and explicit decisions @@ -1851,8 +2017,9 @@ Do not implement these until their listed decision is settled and appended to th the production generation-admission strategy. There is no temporary foundation harness. - **Provider generation/system integration:** verify landed pi-ai messages-only behavior and define complete generation input/checkpoint/result/failure/abort schemas, retry/usage dedupe and deferred recovery table. -- **Tool/post_tools/output/preview:** define one coherent author API, tool/job scratch-output sharing, - bounded/spill operations and preview reconstruction. Job-first only; no arbitrary promise adoption. +- **Tool/post_tools:** define the complete built-in payload/checkpoint/outcome schemas, bounded/spill policy + and model-visible projection on the settled `TaskOutput` mechanism. Job-first only; no arbitrary promise + adoption. - **Hooks:** define task identity and namespaced scratch capability, then confirm hook points/typed decisions. - **Collapse provider implementation:** define summarizer request/checkpoint/result schemas and retry budgets; the context/head/chain foundation above is ready. @@ -1860,7 +2027,8 @@ Do not implement these until their listed decision is settled and appended to th the settled output API; ownership/admission/wait foundation above is ready. - **Typed system sections:** define the concrete draft/persistence API and preparation staleness retry code after pi-ai verification; section 14.4 fixes required semantics. -- **Full preview/client integration:** settle tracker/sink API; delivery coalescing remains deferred. +- **Client/rendering integration:** expose renderer projections over task-output watch state; delivery + coalescing remains deferred. - **Conversation deletion, runtime schema bundle, renderer registry/layouts and migration:** later milestones, not foundation. Root deletion is always forbidden; descendant/fork-reference deletion policy must be specified before adding a delete API. diff --git a/packages/agent/src/harness/pico/addresses.ts b/packages/agent/src/harness/pico/addresses.ts new file mode 100644 index 000000000..9d12acc09 --- /dev/null +++ b/packages/agent/src/harness/pico/addresses.ts @@ -0,0 +1,77 @@ +import type { Id, JsonValue } from "./core.ts"; + +export type Scope = + | { readonly type: "session" } + | { readonly type: "conversation"; readonly conversationId: Id } + | { readonly type: "task"; readonly taskId: Id } + | { readonly type: "shared"; readonly id: Id }; + +declare const addressType: unique symbol; + +export interface Address { + readonly scope: Scope; + readonly namespace: string; + readonly key?: string; + readonly collection: "value" | "list"; + readonly rewind: boolean; + readonly [addressType]?: T; +} + +export interface Value extends Address { + readonly collection: "value"; +} + +export interface List extends Address { + readonly collection: "list"; +} + +export interface Element { + readonly id: Id; + readonly value: T; +} + +type StickyOptions = { readonly key?: string; readonly rewind?: false }; +type ConversationOptions = { readonly key?: string; readonly rewind: boolean }; +type StickyScope = Extract; +type ConversationScope = Extract; +type Options = StickyOptions | ConversationOptions; + +export function defineValue( + scope: StickyScope, + namespace: string, + options?: StickyOptions, +): Value; +export function defineValue( + scope: ConversationScope, + namespace: string, + options: ConversationOptions, +): Value; +export function defineValue(scope: Scope, namespace: string, options?: Options): Value { + return Object.freeze({ + ...options, + scope, + namespace, + collection: "value", + rewind: options?.rewind ?? false, + }); +} + +export function defineList( + scope: StickyScope, + namespace: string, + options?: StickyOptions, +): List; +export function defineList( + scope: ConversationScope, + namespace: string, + options: ConversationOptions, +): List; +export function defineList(scope: Scope, namespace: string, options?: Options): List { + return Object.freeze({ + ...options, + scope, + namespace, + collection: "list", + rewind: options?.rewind ?? false, + }); +} diff --git a/packages/agent/src/harness/pico/core.ts b/packages/agent/src/harness/pico/core.ts new file mode 100644 index 000000000..495a66e7c --- /dev/null +++ b/packages/agent/src/harness/pico/core.ts @@ -0,0 +1,8 @@ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; + +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +export type Id = number; diff --git a/packages/agent/src/harness/pico/entries.ts b/packages/agent/src/harness/pico/entries.ts new file mode 100644 index 000000000..ead28a2ef --- /dev/null +++ b/packages/agent/src/harness/pico/entries.ts @@ -0,0 +1,66 @@ +import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; +import type { Id, JsonValue } from "./core.ts"; + +export type ContextEdit = + | { readonly target: Id; readonly action: "omit" } + | { readonly target: Id; readonly action: "replace"; readonly messages: readonly Message[] }; + +export interface EntryIdentity { + readonly id: Id; + readonly conversationId: Id; + readonly kind: string; + readonly byTaskId?: Id; +} + +export type EntryBase = EntryIdentity; + +export interface EntryData { + readonly data: D; +} + +export interface ModelProjection { + readonly model: readonly M[]; +} + +export interface ContextHead { + readonly head: Id; +} + +export interface ContextEdits { + readonly edits: readonly ContextEdit[]; +} + +export type Entry = EntryBase & Partial; + +export type EntryInput = Omit & + (E extends ContextHead ? { readonly head: Id | "self" } : { readonly head?: never }); + +export interface EntryKind { + readonly kind: string; + is(entry: Entry | undefined): entry is E; +} + +export function defineEntry(kind: string): EntryKind { + return Object.freeze({ + kind, + is(entry: Entry | undefined): entry is E { + return entry?.kind === kind; + }, + }); +} + +export interface Conversation { + readonly id: Id; + readonly parent?: { readonly conversationId: Id; readonly at: Id }; + readonly owner?: Id; +} + +export type UserEntry = EntryBase & { readonly model: readonly [UserMessage] }; +export type AssistantEntry = EntryBase & { readonly model: readonly [AssistantMessage] }; +export type ToolResultEntry = EntryBase & + EntryData & { readonly model: readonly [ToolResultMessage] }; +export type NoticeEntry = EntryBase & Partial & { readonly model: readonly [UserMessage] }; +export type SummaryEntry = EntryBase & + EntryData<{ readonly through: Id }> & { readonly model: readonly [UserMessage] } & ContextHead; +export type HandoffEntry = EntryBase & { readonly model: readonly [UserMessage] } & ContextHead; +export type ResetEntry = EntryBase & ContextHead; diff --git a/packages/agent/src/harness/pico/index.ts b/packages/agent/src/harness/pico/index.ts new file mode 100644 index 000000000..a5c9e4381 --- /dev/null +++ b/packages/agent/src/harness/pico/index.ts @@ -0,0 +1,5 @@ +export * from "./addresses.ts"; +export * from "./core.ts"; +export * from "./entries.ts"; +export * from "./runtime.ts"; +export * from "./tasks.ts"; diff --git a/packages/agent/src/harness/pico/runtime.ts b/packages/agent/src/harness/pico/runtime.ts new file mode 100644 index 000000000..3d563ad82 --- /dev/null +++ b/packages/agent/src/harness/pico/runtime.ts @@ -0,0 +1,300 @@ +import type { Context } from "@earendil-works/chord"; +import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; +import type { Element, List, Value } from "./addresses.ts"; +import type { Id, JsonValue } from "./core.ts"; +import type { ContextEdit, Conversation, Entry, EntryInput, EntryKind } from "./entries.ts"; +import type { + AnyTaskKind, + Completion, + ExactJsonInput, + InputOf, + NoExtra, + OutputOf, + RunningTask, + Task, + TaskCheckpoint, + TaskOf, + TaskOutputRef, +} from "./tasks.ts"; + +export interface Cursor { + readonly after?: Id; + readonly before?: Id; + readonly limit: number; +} + +export interface Page { + readonly items: readonly T[]; + readonly next?: Id; + readonly readAt: Id; +} + +export interface ListQuery extends Cursor { + readonly at?: Id; +} + +export interface TxReaders { + getConversation(id: Id): Promise; + getEntry(id: Id): Promise; + getEntry(kind: EntryKind, id: Id): Promise; + getEntries(ids: readonly Id[]): Promise>; + getTask(id: Id): Promise; + getTask(kind: K, id: Id): Promise | undefined>; + getTasks(ids: readonly Id[]): Promise>; +} + +export interface TxValue { + get(at?: Id): Promise; + set(value: T): void; + delete(): void; +} + +export interface TxList { + read(query: ListQuery): Promise>>; + append(value: T): Id; + remove(id: Id): void; + clear(): void; +} + +export interface Acceptance { + readonly requestId?: string; + readonly conversationId: Id; + readonly inputId: Id; +} + +interface TaskSpecBase { + readonly conversationId?: Id; + readonly input: I; + readonly after?: readonly Id[]; + readonly background?: true; +} + +export type TaskSpec = TaskSpecBase & + ([O] extends [never] ? { readonly output?: never } : { readonly output?: TaskOutputRef }); + +export interface ConversationCreateSpec { + readonly parent?: { readonly conversationId: Id; readonly at: Id }; +} + +export type UserInput = string | readonly (TextContent | ImageContent)[]; + +export interface StoredEntryDraft { + readonly kind: string; + readonly data?: JsonValue; + readonly model?: readonly Message[]; + readonly head?: Id | "self"; + readonly edits?: readonly ContextEdit[]; +} + +export type QueuedInput = + | { readonly mode: "steer" | "followUp" | "nextRun"; readonly input: UserInput; readonly requestId?: string } + | { readonly mode: "write"; readonly entry: StoredEntryDraft; readonly requestId?: string }; + +export type InputResult = + | { readonly status: "queued"; readonly requestId?: string } + | { readonly status: "placed"; readonly requestId?: string; readonly entry: Id } + | { readonly status: "done"; readonly requestId?: string; readonly entry: Id; readonly answer?: Id } + | { + readonly status: "unanswered"; + readonly requestId?: string; + readonly entry?: Id; + readonly reason: "terminated" | "aborted" | "failed" | "stale"; + readonly detail?: string; + }; + +export type TerminalInputResult = Extract; + +export interface AcceptOptions { + readonly input: UserInput; + readonly requestId?: string; + readonly whenBusy?: "followUp" | "steer" | "reject"; +} + +export interface BaseTaskTx extends TxReaders { + checkpoint(value: C): void; + task, OutputOf>>( + kind: K, + spec: NoExtra, OutputOf>, S> & { + readonly input: ExactJsonInput, S["input"]>; + }, + ): Id; + createConversation(spec: ConversationCreateSpec): Id; + value(address: Value): TxValue; + list(address: List): TxList; + accept(conversationId: Id, options: AcceptOptions): Promise; + queueInput(conversationId: Id, input: QueuedInput): Promise; + write( + conversationId: Id, + kind: EntryKind, + input: EntryInput, + requestId?: string, + ): Promise; +} + +export interface TurnTaskTx extends BaseTaskTx { + entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; +} + +export interface PublicValue { + get(at: Id | undefined, ctx: Context): Promise; + set(value: T, ctx: Context): Promise; + delete(ctx: Context): Promise; +} + +export interface PublicList { + read(query: ListQuery, ctx: Context): Promise>>; + append(value: T, ctx: Context): Promise; + remove(id: Id, ctx: Context): Promise; + clear(ctx: Context): Promise; +} + +export interface TaskOutput { + readonly ref: TaskOutputRef; + read(ctx: Context): Promise; + mutate(mutator: (state: O) => undefined, ctx: Context): Promise; + replace(value: O, ctx: Context): Promise; +} + +export interface ReadonlyTaskOutput { + readonly ref: TaskOutputRef; + read(ctx: Context): Promise; +} + +type RuntimeOutput = [O] extends [never] ? unknown : { readonly output: TaskOutput }; + +type AbortRuntimeOutput = [O] extends [never] ? unknown : { readonly output: ReadonlyTaskOutput }; + +type FinalOutput = [O] extends [never] ? unknown : { readonly output: O }; + +export interface ScratchTx { + value(address: Value): TxValue; + list(address: List): TxList; +} + +export interface ScratchReader { + value(address: Value): Pick, "get">; + list(address: List): Pick, "read">; +} + +export interface TaskConversation { + readonly id: Id; + accept(options: AcceptOptions, ctx: Context): Promise; + queueInput(input: QueuedInput, ctx: Context): Promise; + write( + kind: EntryKind, + input: EntryInput, + requestId: string | undefined, + ctx: Context, + ): Promise; + result(inputId: Id, ctx: Context): Promise; + waitForInput(inputId: Id, ctx: Context): Promise; + abortInput(inputId: Id, ctx: Context): Promise<"aborted" | "already_placed" | "not_found">; + value(address: Value): PublicValue; + list(address: List): PublicList; +} + +export interface AbortTaskConversation { + readonly id: Id; + write( + kind: EntryKind, + input: EntryInput, + requestId: string | undefined, + ctx: Context, + ): Promise; + result(inputId: Id, ctx: Context): Promise; + waitForInput(inputId: Id, ctx: Context): Promise; + value(address: Value): PublicValue; + list(address: List): PublicList; +} + +export interface BaseTaskRuntime { + readonly taskId: Id; + commit(build: (tx: BaseTaskTx, current: RunningTask) => T | Promise, ctx: Context): Promise; + scratch(build: (tx: ScratchTx) => T | Promise, ctx: Context): Promise; + conversation(id: Id, ctx: Context): Promise; + waitForTask(id: Id, ctx: Context): Promise; + abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">; + now(): number; + sleep(untilMs: number, ctx: Context): Promise; +} + +export interface TurnTaskRuntime + extends Omit, "commit"> { + commit(build: (tx: TurnTaskTx, current: RunningTask) => T | Promise, ctx: Context): Promise; +} + +export type RuntimeFor< + I extends JsonValue, + C extends TaskCheckpoint, + O extends object, + T extends false | true, +> = (T extends true ? TurnTaskRuntime : BaseTaskRuntime) & RuntimeOutput; + +export interface TerminalTx extends BaseTaskTx {} +export interface TurnTerminalTx extends TurnTaskTx {} + +export type FinalTx = (T extends true + ? TurnTerminalTx + : TerminalTx) & + FinalOutput; + +export type TerminalClosure< + I extends JsonValue, + C extends TaskCheckpoint, + R extends JsonValue, + F extends JsonValue, + O extends object, + T extends false | true, +> = (tx: FinalTx, current: RunningTask) => Completion | Promise>; + +export interface AbortTx extends TxReaders { + checkpoint(value: C): void; + value(address: Value): TxValue; + list(address: List): TxList; + write( + conversationId: Id, + kind: EntryKind, + input: EntryInput, + requestId?: string, + ): Promise; + markTask(id: Id): Promise<"marked" | "terminal">; +} + +export interface TurnAbortTx extends AbortTx { + entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; +} + +export type AbortFinalTx = (T extends true + ? TurnAbortTx + : AbortTx) & + FinalOutput; + +export type AbortClosure< + I extends JsonValue, + C extends TaskCheckpoint, + A extends JsonValue, + O extends object, + T extends false | true, +> = (tx: AbortFinalTx, current: RunningTask) => A | Promise; + +export interface BaseAbortRuntime { + readonly taskId: Id; + commit(build: (tx: AbortTx, current: RunningTask) => T | Promise, ctx: Context): Promise; + scratch(read: (scratch: ScratchReader) => T | Promise, ctx: Context): Promise; + conversation(id: Id, ctx: Context): Promise; + waitForTask(id: Id, ctx: Context): Promise; + abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">; + now(): number; +} + +export interface TurnAbortRuntime + extends Omit, "commit"> { + commit(build: (tx: TurnAbortTx, current: RunningTask) => T | Promise, ctx: Context): Promise; +} + +export type AbortRuntimeFor< + I extends JsonValue, + C extends TaskCheckpoint, + O extends object, + T extends false | true, +> = (T extends true ? TurnAbortRuntime : BaseAbortRuntime) & AbortRuntimeOutput; diff --git a/packages/agent/src/harness/pico/tasks.ts b/packages/agent/src/harness/pico/tasks.ts new file mode 100644 index 000000000..30b2d88e4 --- /dev/null +++ b/packages/agent/src/harness/pico/tasks.ts @@ -0,0 +1,207 @@ +import type { Context } from "@earendil-works/chord"; +import type { Id, JsonObject, JsonValue } from "./core.ts"; +import type { AbortClosure, AbortRuntimeFor, RuntimeFor, TerminalClosure } from "./runtime.ts"; + +export interface TaskCheckpoint extends JsonObject { + readonly phase: string; +} + +export type NoCheckpoint = never; + +declare const taskOutputType: unique symbol; + +export interface TaskOutputKind { + readonly kind: string; + readonly [taskOutputType]?: O; +} + +export function defineTaskOutput(kind: string): TaskOutputKind { + return Object.freeze({ kind }); +} + +export interface TaskOutputSpec { + readonly kind: TaskOutputKind; + initial(input: I): O; +} + +export interface StoredTaskOutputRef { + readonly id: Id; + readonly kind: string; +} + +export interface TaskOutputRef extends StoredTaskOutputRef { + readonly [taskOutputType]?: O; +} + +export type TaskOutputField = [O] extends [never] ? unknown : { readonly output: TaskOutputRef }; + +type TaskOutputDefinition = [O] extends [never] + ? { readonly output?: never } + : { readonly output: TaskOutputSpec }; + +export type TaskOutcome = + | { readonly status: "completed"; readonly result: R } + | { readonly status: "failed"; readonly failure: F } + | { readonly status: "aborted"; readonly result: A } + | { readonly status: "orphaned" }; + +export interface TaskBase { + readonly id: Id; + readonly conversationId: Id; + readonly kind: string; + readonly input: I; + readonly checkpoint?: C; + readonly after: readonly Id[]; + readonly background?: true; + readonly turn?: true; + readonly owns: readonly Id[]; + readonly output?: StoredTaskOutputRef; + readonly abort?: true; +} + +export type Task< + I extends JsonValue = JsonValue, + C extends TaskCheckpoint = TaskCheckpoint, + R extends JsonValue = JsonValue, + F extends JsonValue = JsonValue, + A extends JsonValue = JsonValue, +> = TaskBase & + ( + | { readonly status: "pending" | "running"; readonly outcome?: never } + | { readonly status: "terminal"; readonly outcome: TaskOutcome } + ); + +export type RunningTask = Omit< + TaskBase, + "output" +> & + TaskOutputField & { + readonly status: "running"; + readonly outcome?: never; + }; + +export type Completion = + | { readonly status: "completed"; readonly result: R } + | { readonly status: "failed"; readonly failure: F }; + +const taskKindBrand: unique symbol = Symbol("pico.taskKind"); + +export interface TaskKindBase { + readonly kind: string; + readonly turn: false | true; + readonly [taskKindBrand]: true; +} + +interface TaskKindMethods< + I extends JsonValue, + C extends TaskCheckpoint, + R extends JsonValue, + F extends JsonValue, + A extends JsonValue, + O extends object, + T extends false | true, +> extends TaskKindBase { + readonly turn: T; + execute( + task: RunningTask, + runtime: RuntimeFor, + ctx: Context, + ): Promise>; + recover( + task: RunningTask, + runtime: RuntimeFor, + ctx: Context, + ): Promise>; + abort( + task: RunningTask, + runtime: AbortRuntimeFor, + ctx: Context, + ): Promise>; +} + +export type TaskKind< + I extends JsonValue, + C extends TaskCheckpoint, + R extends JsonValue, + F extends JsonValue, + A extends JsonValue, + O extends object = never, + T extends false | true = false, +> = TaskKindMethods & TaskOutputDefinition; + +export type NoExtra = Actual & Record, never>; + +export type ExactJsonInput = Expected extends readonly JsonValue[] + ? Actual + : Expected extends JsonObject + ? Actual & Record, never> + : Actual; + +export type NonTurnKindDefinition< + I extends JsonValue, + C extends TaskCheckpoint, + R extends JsonValue, + F extends JsonValue, + A extends JsonValue, + O extends object, +> = Omit, "turn" | typeof taskKindBrand> & { readonly turn?: false }; + +export type TurnKindDefinition< + I extends JsonValue, + C extends TaskCheckpoint, + R extends JsonValue, + F extends JsonValue, + A extends JsonValue, + O extends object, +> = Omit, typeof taskKindBrand>; + +export interface TaskKindFactory< + I extends JsonValue, + C extends TaskCheckpoint, + R extends JsonValue, + F extends JsonValue, + A extends JsonValue, + O extends object, +> { + >( + definition: NoExtra, D>, + ): D & TaskKind; + >( + definition: NoExtra, D>, + ): D & TaskKind; +} + +export function defineTask< + I extends JsonValue, + C extends TaskCheckpoint, + R extends JsonValue, + F extends JsonValue, + A extends JsonValue, + O extends object = never, +>(): TaskKindFactory { + const define = (definition: NonTurnKindDefinition | TurnKindDefinition) => + Object.freeze({ + ...definition, + turn: definition.turn ?? false, + [taskKindBrand]: true as const, + }); + return define as TaskKindFactory; +} + +export type AnyTaskKind = TaskKindBase; +export type PayloadsOf = K extends TaskKind + ? { input: I; checkpoint: C; result: R; failure: F; aborted: A; output: O } + : never; +export type InputOf = PayloadsOf["input"]; +export type CheckpointOf = PayloadsOf["checkpoint"]; +export type ResultOf = PayloadsOf["result"]; +export type FailureOf = PayloadsOf["failure"]; +export type AbortedOf = PayloadsOf["aborted"]; +export type OutputOf = PayloadsOf["output"]; +export type TurnOf = K["turn"]; +type WithoutOutput = T extends unknown ? Omit : never; + +export type TaskOf = WithoutOutput< + Task, CheckpointOf, ResultOf, FailureOf, AbortedOf> +> & + TaskOutputField>; diff --git a/packages/agent/test/harness/pico/tsconfig.json b/packages/agent/test/harness/pico/tsconfig.json new file mode 100644 index 000000000..71d638f7e --- /dev/null +++ b/packages/agent/test/harness/pico/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["../../../src/harness/pico/**/*.ts", "./types.compile.ts"] +} diff --git a/packages/agent/test/harness/pico/types.compile.ts b/packages/agent/test/harness/pico/types.compile.ts new file mode 100644 index 000000000..54b77ce68 --- /dev/null +++ b/packages/agent/test/harness/pico/types.compile.ts @@ -0,0 +1,379 @@ +import type { Context } from "@earendil-works/chord"; +import type { + AbortClosure, + AbortedOf, + AbortRuntimeFor, + BaseAbortRuntime, + BaseTaskRuntime, + BaseTaskTx, + CheckpointOf, + Entry, + EntryInput, + ExactJsonInput, + FailureOf, + InputOf, + JsonValue, + List, + OutputOf, + PayloadsOf, + ReadonlyTaskOutput, + ResultOf, + RuntimeFor, + SummaryEntry, + TaskOf, + TaskOutcome, + TaskOutput, + TaskOutputRef, + TerminalClosure, + TurnAbortRuntime, + TurnOf, + TurnTaskRuntime, + Value, +} from "../../../src/harness/pico/index.ts"; +import { defineEntry, defineList, defineTask, defineTaskOutput, defineValue } from "../../../src/harness/pico/index.ts"; + +type Equal = (() => Type extends Left ? 1 : 2) extends () => Type extends Right ? 1 : 2 + ? (() => Type extends Right ? 1 : 2) extends () => Type extends Left ? 1 : 2 + ? true + : false + : false; +type Assert = Condition; +type HasEntry = "entry" extends keyof Surface ? true : false; +type HasOutput = "output" extends keyof Surface ? true : false; + +type TestInput = { + readonly prompt: string; + readonly nested: { readonly count: number }; +}; +type TestCheckpoint = { + readonly phase: "ready"; + readonly cursor: number; +}; +type SameCheckpoint = { + readonly phase: "ready"; + readonly cursor: number; +}; +type IncompatibleCheckpoint = { + readonly phase: "ready"; + readonly token: string; +}; +type TestResult = { readonly answer: string }; +type TestFailure = { readonly code: "failed" }; +type TestAborted = { readonly cleaned: boolean }; +type TestOutput = { text: string; chunks: string[] }; + +type JsonAssertions = [ + Assert, + Assert, + Assert, + Assert<{ readonly value: undefined } extends JsonValue ? false : true>, +]; + +const nonTurnKind = defineTask()({ + kind: "test.non_turn", + execute() { + return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } })); + }, + recover() { + return Promise.resolve(() => ({ status: "failed", failure: { code: "failed" } })); + }, + abort() { + return Promise.resolve(() => ({ cleaned: true })); + }, +}); + +const turnKind = defineTask()({ + kind: "test.turn", + turn: true, + execute() { + return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } })); + }, + recover() { + return Promise.resolve(() => ({ status: "failed", failure: { code: "failed" } })); + }, + abort() { + return Promise.resolve(() => ({ cleaned: true })); + }, +}); + +const sameCheckpointKind = defineTask()({ + kind: "test.same_checkpoint", + execute() { + return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } })); + }, + recover() { + return Promise.resolve(() => ({ status: "failed", failure: { code: "failed" } })); + }, + abort() { + return Promise.resolve(() => ({ cleaned: true })); + }, +}); + +const testOutputKind = defineTaskOutput("test.output"); +const outputTaskKind = defineTask()({ + kind: "test.with_output", + output: { + kind: testOutputKind, + initial(input) { + return { text: input.prompt, chunks: [] }; + }, + }, + execute() { + return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } })); + }, + recover() { + return Promise.resolve(() => ({ status: "failed", failure: { code: "failed" } })); + }, + abort() { + return Promise.resolve(() => ({ cleaned: true })); + }, +}); + +const incompatibleCheckpointKind = defineTask< + TestInput, + IncompatibleCheckpoint, + TestResult, + TestFailure, + TestAborted +>()({ + kind: "test.incompatible_checkpoint", + execute() { + return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } })); + }, + recover() { + return Promise.resolve(() => ({ status: "failed", failure: { code: "failed" } })); + }, + abort() { + return Promise.resolve(() => ({ cleaned: true })); + }, +}); + +type NonTurnRuntimeTx = Parameters["commit"]>[0]>[0]; +type TurnRuntimeTx = Parameters["commit"]>[0]>[0]; +type NonTurnAbortTx = Parameters["commit"]>[0]>[0]; +type TurnRuntimeAbortTx = Parameters< + Parameters["commit"]>[0] +>[0]; +type NonTurnFinalTx = Parameters>[0]; +type TurnFinalTx = Parameters>[0]; +type NonTurnAbortFinalTx = Parameters>[0]; +type TurnAbortFinalTx = Parameters>[0]; +type OutputFinalTx = Parameters< + TerminalClosure +>[0]; +type OutputAbortFinalTx = Parameters>[0]; + +type KindAssertions = [ + Assert, false>>, + Assert, true>>, + Assert, TestInput>>, + Assert, TestCheckpoint>>, + Assert, TestResult>>, + Assert, TestFailure>>, + Assert, TestAborted>>, + Assert["aborted"], TestAborted>>, + Assert, never>>, + Assert, TestOutput>>, + Assert[1], BaseTaskRuntime>>, + Assert[1]["output"], TaskOutput>>, + Assert["mutate"]>[0]>, undefined>>, + Assert[1], TurnTaskRuntime>>, + Assert[1], BaseAbortRuntime>>, + Assert[1], TurnAbortRuntime>>, + Assert[1]["output"], ReadonlyTaskOutput>>, + Assert[1]>, false>>, + Assert[1]>, true>>, + Assert>, + Assert>, + Assert, false>>, + Assert, true>>, + Assert, false>>, + Assert, true>>, + Assert, false>>, + Assert, true>>, + Assert, false>>, + Assert, true>>, + Assert<"sleep" extends keyof BaseAbortRuntime ? false : true>, +]; + +type KindOutcome = Extract, { readonly status: "terminal" }>["outcome"]; +type OutcomeAssertions = [ + Assert>>, + Assert["result"], TestResult>>, + Assert["failure"], TestFailure>>, + Assert["result"], TestAborted>>, +]; + +const summaryKind = defineEntry("pi.summary"); +type SummaryInput = EntryInput; +type EntryAssertions = [ + Assert>, + Assert>, + Assert<"id" extends keyof SummaryInput ? false : true>, + Assert<"conversationId" extends keyof SummaryInput ? false : true>, + Assert<"kind" extends keyof SummaryInput ? false : true>, + Assert<"byTaskId" extends keyof SummaryInput ? false : true>, +]; + +function verifyTaskInputs( + tx: BaseTaskTx, + outputRef: TaskOutputRef, + wrongOutputRef: TaskOutputRef<{ count: number }>, +): void { + tx.task(nonTurnKind, { + input: { prompt: "hello", nested: { count: 1 } }, + }); + tx.task(outputTaskKind, { + input: { prompt: "own output", nested: { count: 1 } }, + }); + tx.task(outputTaskKind, { + input: { prompt: "shared output", nested: { count: 1 } }, + output: outputRef, + }); + // @ts-expect-error a no-output kind cannot receive an output reference + tx.task(nonTurnKind, { input: { prompt: "hello", nested: { count: 1 } }, output: outputRef }); + tx.task(outputTaskKind, { + input: { prompt: "wrong output", nested: { count: 1 } }, + // @ts-expect-error a shared output reference must have the kind's output type + output: wrongOutputRef, + }); + + const exactVariable = { + conversationId: 1, + input: { prompt: "hello", nested: { count: 1 } }, + background: true, + } as const; + tx.task(nonTurnKind, exactVariable); + + // Top-level exactness intentionally does not recursively reject extra nested fields. + tx.task(nonTurnKind, { + input: { prompt: "hello", nested: { count: 1, extension: true } }, + }); + + // @ts-expect-error task input literals reject extra top-level fields + tx.task(nonTurnKind, { input: { prompt: "hello", nested: { count: 1 }, extension: true } }); + + const extraInputVariable = { prompt: "hello", nested: { count: 1 }, extension: true }; + // @ts-expect-error task input variables reject visible extra top-level fields + tx.task(nonTurnKind, { input: extraInputVariable }); + + const inputBase = { prompt: "hello", nested: { count: 1 } }; + // @ts-expect-error task input spreads reject visible extra top-level fields + tx.task(nonTurnKind, { input: { ...inputBase, extension: true } }); + + const extraSpecVariable = { + input: { prompt: "hello", nested: { count: 1 } }, + priority: 1, + }; + // @ts-expect-error task specs reject visible extra top-level fields + tx.task(nonTurnKind, extraSpecVariable); + + tx.checkpoint({ phase: "ready", cursor: 1 }); + // @ts-expect-error every checkpoint requires its phase + tx.checkpoint({ cursor: 1 }); + // @ts-expect-error checkpoint replacement requires the complete kind-specific shape + tx.checkpoint({ phase: "ready" }); + // @ts-expect-error an incompatible checkpoint shape is rejected + tx.checkpoint({ phase: "ready", token: "cursor" }); +} + +function verifyOutputMutation( + output: TaskOutput, + readonlyOutput: ReadonlyTaskOutput, + ctx: Context, +): void { + void output.mutate((state) => { + state.chunks.push("chunk"); + }, ctx); + // @ts-expect-error task-output mutation callbacks must be synchronous + void output.mutate(async (state) => { + state.chunks.push("chunk"); + }, ctx); + // @ts-expect-error fresh abort output is read-only + void readonlyOutput.mutate; +} + +function verifyCheckpointCompatibility( + checkpoint: CheckpointOf, + incompatible: CheckpointOf, +): void { + // Checkpoints are structural: an independently declared identical shape is assignable. + const structurallyCompatible: CheckpointOf = checkpoint; + void structurallyCompatible; + // @ts-expect-error a differently shaped kind checkpoint is not assignable + const structurallyIncompatible: CheckpointOf = incompatible; + void structurallyIncompatible; +} + +function verifyOutcomes(outcome: KindOutcome): void { + const completed: KindOutcome = { status: "completed", result: { answer: "done" } }; + const failed: KindOutcome = { status: "failed", failure: { code: "failed" } }; + const aborted: KindOutcome = { status: "aborted", result: { cleaned: true } }; + const orphaned: KindOutcome = { status: "orphaned" }; + // @ts-expect-error completed outcomes require this kind's result payload + const wrongCompleted: KindOutcome = { status: "completed", result: { answer: 1 } }; + // @ts-expect-error failed outcomes require this kind's failure payload + const wrongFailure: KindOutcome = { status: "failed", failure: { code: "other" } }; + // @ts-expect-error aborted outcomes require this kind's abort payload + const wrongAborted: KindOutcome = { status: "aborted", result: { cleaned: "yes" } }; + void outcome; + void completed; + void failed; + void aborted; + void orphaned; + void wrongCompleted; + void wrongFailure; + void wrongAborted; +} + +function verifyAddresses(): void { + const sessionValue = defineValue({ type: "session" }, "plugin.name"); + const taskList = defineList({ type: "task", taskId: 2 }, "plugin.items", { key: "active" }); + const sharedList = defineList({ type: "shared", id: 4 }, "plugin.shared"); + const conversationValue = defineValue({ type: "conversation", conversationId: 3 }, "plugin.result", { + rewind: true, + }); + const conversationList = defineList({ type: "conversation", conversationId: 3 }, "plugin.events", { + rewind: false, + }); + const typedSessionValue: Value = sessionValue; + const typedTaskList: List = taskList; + void typedSessionValue; + void typedTaskList; + void sharedList; + void conversationValue; + void conversationList; + + // @ts-expect-error conversation addresses explicitly select rewind behavior + defineValue({ type: "conversation", conversationId: 3 }, "plugin.value"); + // @ts-expect-error session addresses are sticky + defineValue({ type: "session" }, "plugin.value", { rewind: true }); + // @ts-expect-error task addresses are never rewindable + defineList({ type: "task", taskId: 2 }, "plugin.list", { rewind: true }); + // @ts-expect-error shared addresses are never rewindable + defineList({ type: "shared", id: 4 }, "plugin.shared", { rewind: true }); + // @ts-expect-error address payloads are strict JSON + defineValue({ type: "session" }, "plugin.date"); +} + +type ExactInputAssertion = Assert< + Equal["extension"], never> +>; + +declare const jsonAssertions: JsonAssertions; +declare const kindAssertions: KindAssertions; +declare const outcomeAssertions: OutcomeAssertions; +declare const entryAssertions: EntryAssertions; +declare const exactInputAssertion: ExactInputAssertion; +declare const entry: Entry; +void jsonAssertions; +void kindAssertions; +void outcomeAssertions; +void entryAssertions; +void exactInputAssertion; +void entry; +void verifyTaskInputs; +void verifyOutputMutation; +void verifyCheckpointCompatibility; +void verifyOutcomes; +void verifyAddresses;