From 378977cd3d68b07cffb364425e7bb0c5bc2a2f21 Mon Sep 17 00:00:00 2001 From: badlogic Date: Thu, 10 Sep 2026 21:08:28 +0000 Subject: [PATCH] docs(agent): simplify input results and unify queued input --- packages/agent/docs/pico/pico-usage-guide.md | 120 ++++++++------ packages/agent/docs/pico/pico-v3.md | 155 ++++++++++--------- 2 files changed, 150 insertions(+), 125 deletions(-) diff --git a/packages/agent/docs/pico/pico-usage-guide.md b/packages/agent/docs/pico/pico-usage-guide.md index d7c2d2c78..84c39d23c 100644 --- a/packages/agent/docs/pico/pico-usage-guide.md +++ b/packages/agent/docs/pico/pico-usage-guide.md @@ -179,7 +179,7 @@ w.start(event => { }); // accept the input, drive the conversation until it is idle, return the answer to that input -const answer = await c.prompt('Inspect the parser and list the public API', undefined, call); +const answer = await c.prompt({ input: 'Inspect the parser and list the public API' }, call); console.log(answer?.model[0]?.content); w.unsubscribe(); @@ -470,9 +470,9 @@ Nothing to do in any of them. first turn diffs the two: ```typescript - const b = await c.fork(12, undefined, call); // config as of 12: tools [read, write, bash] + const b = await c.fork({ at: 12 }, call); // config as of 12: tools [read, write, bash] await b.settings.set({ selectedTools: ['read'] }, call); - await b.prompt('...', undefined, call); // b's turn: toolsRemoved [write, bash]; the prefix's 11 is still the baseline + await b.prompt({ input: '...' }, call); // b's turn: toolsRemoved [write, bash]; the prefix's 11 is still the baseline ``` - **A restart** with a changed host (new skill, different cwd) emits "these sections now apply" @@ -486,8 +486,8 @@ Nothing to do in any of them. explicit result for that input. ```typescript -const answer = await c.prompt('Inspect the parser', undefined, call); -// AssistantEntry | undefined (the run ended without an answer: aborted, failed) +const answer = await c.prompt({ input: 'Inspect the parser' }, call); +// AssistantEntry | undefined (the run ended without an answer) ``` The pieces are available separately. `inputId` is always the id of the accepted `pi.inbox` list @@ -495,66 +495,84 @@ element, even when idle acceptance places and removes it in the same commit. Gen `post_tools` carry input ids explicitly, so result lookup never scans the transcript: ```typescript -const { inputId } = await c.accept('Inspect the parser', { requestId: 'req-42' }, call); -const outcome = await c.drive(call); // 'idle' | 'closed' +const { inputId } = await c.accept({ input: 'Inspect the parser', requestId: 'req-42' }, call); +const outcome = await c.drive(call); // 'idle' | 'closed' const result = await c.result(inputId, call); // one sticky-value point read -const answer = result?.status === 'done' - ? await h.getEntry(assistantKind, result.resultEntryId, call) +const answer = result?.status === 'done' && result.answer + ? await h.getEntry(assistantKind, result.answer, call) : undefined; ``` -Results move from `queued` to `running`, then to `done`, `failed`, `cancelled` or `stopped`. -Context-only writes finish as `placed`. Terminal results never change. - -A remote caller that lost the accept response performs an explicit request lookup: +A result moves from `queued` to `placed` (its entry is in the transcript), then to `done` or +`unanswered`. `done` carries the answering entry when one was owed; a context-only `write` is +`done` with no answer, in the commit that placed it. `unanswered` names the cause: ```typescript -const existing = await h.acceptance('req-42', call); -if (existing) { - const conversation = await h.conversation(existing.conversationId, call); - return { - inputId: existing.inputId, - result: await conversation?.result(existing.inputId, call), - }; +switch (result.status) { + case 'queued': return 'waiting to start'; + case 'placed': return 'working'; + case 'done': return result.answer ? render(result.answer) : 'noted'; + case 'unanswered': + return result.reason === 'terminated' ? 'the run stopped itself' + : result.reason === 'aborted' ? 'cancelled' + : 'the model could not be reached'; } -return c.accept(input, { requestId: 'req-42' }, call); ``` -A concurrent duplicate `accept` rejects as `RequestAlreadyAccepted` and identifies the first -receipt. Request ids are optional; there is no payload comparison or silent replay. +Terminal results never change. -`c.drive()` resolves when the conversation's foreground set is idle: no generation, tool or -automatic collapse in that ownership chain is live. `h.drive()` resolves when no foreground task is -live anywhere in the session. Both start eligible background work and keep serving it after they +`accept` is "the user hit enter": idle, it places the entry and creates a generation in one commit; +busy, it queues, as `followUp` by default. Pass `whenBusy: 'steer'` to interrupt the running turn +instead, or `whenBusy: 'reject'` if the caller insists on knowing. Either way you get an `inputId` +and `result(inputId)` tells you which happened, so a caller never has to check whether a run is in +progress first. + +A request key names one acceptance for the life of the session. A caller that lost the response +retries with the same key and gets the same `inputId` back; nothing is written twice and nothing is +compared: + +```typescript +const { inputId } = await c.accept({ input, requestId: 'req-42' }, call); // safe to repeat +const existing = await h.acceptance('req-42', call); // or look it up explicitly +``` + +`c.drive(call)` resolves when the conversation's foreground set is idle: no generation, tool or +automatic collapse in that ownership chain is live. `h.drive(call)` resolves when no foreground task +is live anywhere in the session. Both start eligible background work and keep serving it after they resolve. A separate full-quiescence wait may intentionally never return while a recurring schedule is live. ### Input While Busy -While a turn runs, new input is queued rather than refused. The mode determines the boundary and -input-group behavior: +`accept` covers the common case. `queueInput` is the explicit form when the caller wants a specific +mode, and it is one method taking a tagged union rather than four methods: ```typescript -const steer = await c.steer('Focus on the tokenizer first', call); // post_tools: joins current group; - // final answer: starts next group -const follow = await c.followUp('Then write the tests', call); // after final answer; starts next group -const next = await c.nextRun('Remind me to commit', call); // waits for the next explicit idle accept -const write = await c.write(noteKind, { // next safe boundary; no generation - data: { text: 'user stepped away' }, -}, call); +const steer = await c.queueInput({ mode: 'steer', input: 'Focus on the tokenizer first' }, call); +const follow = await c.queueInput({ mode: 'followUp', input: 'Then write the tests' }, call); +const next = await c.queueInput({ mode: 'nextRun', input: 'Remind me to commit' }, call); +const note = await c.queueInput({ mode: 'write', kind: noteKind, + entry: { data: { text: 'user stepped away' } } }, call); ``` +| mode | lands | asks for | +|---|---|---| +| `steer` | next post_tools, or a final answer | joins the running group at post_tools; starts the next one after an answer | +| `followUp` | after a final answer | starts the next group | +| `nextRun` | the next idle `accept` | joins that acceptance's group | +| `write` | next safe boundary | nothing; `done` with no answer | + When a generation emits calls, its settlement creates every tool plus exactly one `post_tools` -carrying the current input ids. That task places writes and steering, extends the ids and creates the -continuation generation. A final-answer generation resolves its current group, then places +carrying the current input ids. That task places writes and steering, extends the ids and creates +the continuation generation. A final-answer generation resolves its current group, then places steer/followUp items into a new group. `nextRun` remains queued until a later idle `accept`. -The queue exposes complete entry drafts so a UI can render text/images directly. Durable and watch -updates are append/remove/clear operations, not whole-array replacements. Any item can be withdrawn -until it lands: +The queue exposes complete entry drafts so a UI can render text and images directly. Durable and +watch updates are append/remove/clear operations, not whole-array replacements. Any item can be +withdrawn until it lands: ```typescript -await c.cancelQueued(steer.inputId, call); // 'cancelled' | 'not_found' +await c.abortInput(steer.inputId, call); // 'aborted' | 'not_found' ``` ### Aborting @@ -595,11 +613,11 @@ async abort(task, runtime, call) { await runtime.commit(async tx => { for (const id of task.state.inputs) { const address = inputResult(id); - const result = await tx.value(address).get(); - if (result?.status !== 'running') throw new Error(`Invalid active input ${id}`); - tx.value(address).set({ ...result, status: 'cancelled' }); + const r = await tx.value(address).get(); + if (r?.status !== 'placed') throw new Error(`Invalid active input ${id}`); + tx.value(address).set({ status: 'unanswered', requestId: r.requestId, entry: r.entry, reason: 'aborted' }); } - tx.settle(task.id, 'aborted', task.state); + tx.settle(task, { status: 'aborted', ...common(task) }); }, call); } ``` @@ -751,10 +769,10 @@ copied and nothing in the source is deleted. It carries the context as it was at rewindable values in force there, and the prompt-as-sent, so its first turn is exact. ```typescript -const alt = await c.fork(answer.id, undefined, call); // the source keeps running, untouched -await alt.prompt('Try a different implementation', undefined, call); +const alt = await c.fork({ at: answer.id }, call); // the source keeps running, untouched +await alt.prompt({ input: 'Try a different implementation' }, call); -const back = await c.fork(earlier.id, { abort: true }, call); // "go back": aborts the source's foreground first +const back = await c.fork({ at: earlier.id, abort: true }, call); // "go back": aborts the source's foreground first ``` Any transcript entry is a valid fork point, including an assistant with unanswered tool calls or one @@ -785,8 +803,8 @@ Threshold and overflow compaction happen inside the generation; nothing to call. context over, with or without a handoff message: ```typescript -await c.reset('Continue from here: we settled on a recursive-descent parser.', call); // handoff -await c.reset(undefined, call); // /clear +await c.reset({ handoff: 'Continue from here: we settled on a recursive-descent parser.' }, call); +await c.reset(undefined, call); // /clear ``` The transcript keeps everything either way; only the context changes. @@ -811,7 +829,7 @@ subagent({ command: 'stop', id: 88 }) const childId = await c.spawn({ prompt: 'Profile the build', context: 'fresh', values: { inherit: [generationKind.config.model] } }, call); const child = await h.conversation(childId, call); -await child.accept('also check CI', undefined, call); +await child.accept({ input: 'also check CI' }, call); await child.drive(call); // or let h.drive() / the parent's drive carry it await child.abort(call); ``` diff --git a/packages/agent/docs/pico/pico-v3.md b/packages/agent/docs/pico/pico-v3.md index ce87650d6..697d6f0ad 100644 --- a/packages/agent/docs/pico/pico-v3.md +++ b/packages/agent/docs/pico/pico-v3.md @@ -310,7 +310,7 @@ await runtime.commit(tx => { await conv.value(model).set("claude-opus-5", call); // 303, a later commit -const b = await conv.fork(301, undefined, call); // has the result and plan mode; does not have the new model +const b = await conv.fork({ at: 301 }, call); // has the result and plan mode; does not have the new model ``` The builder throws if a rewindable conversation value/list write follows an entry; the plan fails @@ -337,15 +337,17 @@ new identity: ```text 400 append pi.inbox item inputId = 400 -401 inputResult[400] = queued or running +401 inputResult[400] = queued or placed 402 request["req-42"] = { conversationId, inputId:400 } ``` -`harness.acceptance(requestId)` returns that receipt. This is an explicit lookup for a caller whose -accept response may have been lost; `accept` itself does not compare payloads or silently replay an -old call. A second acceptance with the same key rejects as `RequestAlreadyAccepted` and identifies -the first receipt. Without a request id, no mapping is written. The mapping is session state, so it -may follow and reference the newly minted inbox element. +`harness.acceptance(requestId, call)` returns that receipt, for a caller whose accept response may +have been lost. A request key names one acceptance for the life of the session: a second `accept` +or `queueInput` with the same key writes nothing and returns the stored `inputId`, whatever its +payload or mode. Nothing is compared, so no content or digest is kept for that purpose; a client +that reuses a key for different input gets the first acceptance back, and `result(inputId, call)` +says what became of it. Without a request id, no mapping is written. The mapping is session state, +so it may follow and reference the newly minted inbox element. ## 4. State @@ -633,8 +635,9 @@ interface TaskRuntime { ``` The registry maps kind names to kinds; a replacement must understand the persisted statuses and -state of its live tasks. Open reads the live tasks anyway (`inspect()`), so an unknown live kind -rejects before anything runs; terminal tasks of a forgotten kind are only a problem when read typed. +state of its live tasks. Open reads the live tasks anyway (`inspect`), so an unregistered live kind +is handled there rather than rejecting the session (§5.1, §6.4); terminal tasks of a forgotten kind +are only a problem when read typed. Typed access uses the kind as witness. Public/runtime reads are asynchronous and require Call; transaction reads use the transaction view without another Call: @@ -723,9 +726,10 @@ async execute(task, runtime, call) { } if (outcomes.some(o => o.terminate)) { for (const inputId of task.state.inputs) { - const result = await tx.value(inputResult(inputId)).get(); - if (result?.status !== "running") throw new Error(`Invalid active input ${inputId}`); - tx.value(inputResult(inputId)).set({ ...result, status: "stopped" }); + const r = await tx.value(inputResult(inputId)).get(); + if (r?.status !== "placed") throw new Error(`Invalid active input ${inputId}`); + tx.value(inputResult(inputId)).set({ status: "unanswered", requestId: r.requestId, entry: r.entry, + reason: "terminated" }); } return tx.settle(task, { status: "stopped", assistant: task.state.assistant, tools: task.state.tools }); // no successor } @@ -744,8 +748,8 @@ async execute(task, runtime, call) { ``` `inputResult(id)` above is the sticky conversation value address for that input (§8.1), not a -driver operation. The abort handler updates the same owned inputs to `cancelled` and settles in one -commit. The runtime rejects a marked execute commit before its closure runs; no branch is needed +driver operation. The abort handler resolves the same owned inputs as `unanswered` with reason +`aborted` and settles in one commit. The runtime rejects a marked execute commit before its closure runs; no branch is needed in the normal settlement. Inbox-placement helpers above implement exactly the mode table in §8.1. Sequential tools are the same mechanism with `B after: [A]`. @@ -902,7 +906,7 @@ in an ownership tree may require another explicit attachment; never silently wid abortTask(id, call) mark one live task; reject terminal conversation.abort(call) one commit: mark its current foreground ownership closure; - cancel queued steer/followUp in affected conversations; keep write/nextRun + withdraw queued steer/followUp in affected conversations; keep write/nextRun ``` `abort: true` is a durable request, not terminal status. When that mark commits, the current @@ -919,7 +923,7 @@ delivery and then be cancelled; cancellation neither rolls it back nor promises stream returns/throws; execute finishes local finally cleanup and returns A's completion enters line; remove A, reserve fresh abort invocation B line releases; kind.abort(task, runtime, callB) runs -120 partial/error outcome and cancelled input results, if the kind needs them +120 partial/error outcome and unanswered(aborted) input results, if the kind needs them 121 task terminal aborted; scratch retired in the same commit abort method returns; remove B ``` @@ -947,7 +951,7 @@ Fresh cleanup can use only durable records. A tool that creates a cancellable ba the job id and its cancellation policy on its own task in that same commit. Its abort handler uses those references, never locals from execute or a post-mark catch that tries another mutation. Child conversation ownership is already durable. Built-in cleanup marks only the child's live foreground -tasks; it does not call the public conversation abort operation that also cancels queued inputs. +tasks; it does not call the public conversation abort operation that also withdraws queued inputs. This task-only cleanup policy applies on every abort invocation, including after crash/reopen, so shutdown cannot accidentally discard preserved queues during recovery. Cleanup's cancellation request treats an already-terminal target as finished, using an atomic mark-if-live operation internally or handling that specific public @@ -1254,40 +1258,39 @@ outside the line, and their decisions are re-validated inside it. ### 8.1 Accepting input -The inbox is a conversation-scoped sticky list, `pi.inbox`. Its list element id is the stable +Queued input is a conversation-scoped sticky list, `pi.inbox`. Its list element id is the stable `inputId`; its value carries the complete entry draft, so a UI can render queued text or images without another read: ```ts -interface AcceptedEntryDraft extends EntryDraft { readonly kind: string } -type InputMode = "write" | "steer" | "followUp" | "nextRun"; +type UserInput = string | readonly (TextContent | ImageContent)[]; -interface InboxItem { - readonly mode: InputMode; - readonly entry: AcceptedEntryDraft; - readonly requestId?: string; -} +type QueuedInput = + | { readonly mode: "steer" | "followUp" | "nextRun"; readonly input: UserInput; readonly requestId?: string } + | { readonly mode: "write"; readonly kind: EntryKind; readonly entry: EntryInput; readonly requestId?: string }; type InputResult = - | { readonly status: "queued"; readonly requestId?: string } - | { readonly status: "running"; readonly requestId?: string; readonly placementEntryId: Id } - | { readonly status: "placed"; readonly requestId?: string; readonly placementEntryId: Id } - | { readonly status: "done"; readonly requestId?: string; readonly placementEntryId: Id; readonly resultEntryId: Id } - | { readonly status: "failed" | "cancelled" | "stopped"; readonly requestId?: string; - readonly placementEntryId?: Id; readonly reason?: string }; + | { 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"; readonly detail?: string }; ``` -`inputResult(id)` denotes the sticky conversation Value address with namespace `pi.inputResult` -and key `String(id)`, typed as `InputResult`. It is ordinary stored state, not a driver callback. +The three modes that ask for a turn carry user content; a `write` carries its own entry kind and +the same `EntryInput` (§9.2) `Tx.entry` takes, and asks for nothing. `inputResult(id)` denotes the sticky conversation Value address with +namespace `pi.inputResult` and key `String(id)`, typed as `InputResult`. It is ordinary stored +state, not a driver callback. -Acceptance always appends an inbox element and writes `inputResult[inputId]` in the same commit. -When the conversation is idle, that commit immediately places and removes the item and creates a -generation; append plus remove folds to no inbox watch operation. When it is busy, the item remains -queued. `accept` uses `followUp` when it must queue. The four modes are: +`accept({ input, requestId?, whenBusy? }, call)` is "the user hit enter": when the conversation is +idle it places the entry and creates a generation in one commit; when it is busy it queues, +`followUp` by default, `steer` or `reject` if the caller says so. Either way it returns an +`inputId`, and `result(inputId, call)` says which happened. `queueInput(input, call)` is the +explicit form for a specific mode, and `abortInput(inputId, call)` withdraws a queued one. -| Mode | Placement | Input-group effect | +| Mode | Placement | Effect on the answer group | |---|---|---| -| write | next post_tools or final boundary | none; terminal result is `placed` | +| write | next post_tools or final boundary | none; its result is `done` with no `answer` | | steer | next post_tools or final boundary | joins the active group at post_tools; starts the next group after a final answer | | followUp | final-answer boundary | starts the next group | | nextRun | next explicit idle `accept` | joins the group started by that acceptance | @@ -1300,19 +1303,21 @@ input to that answer, places writes, then places selected steer/followUp items i creates its generation. An `on_yield` continuation retains the current group. No tail scan attributes results. -Placement appends `item.entry`, removes the list element and writes its result in one commit. -`cancelQueued(inputId)` removes an item and records `cancelled`; foreground abort does that for -queued steer/followUp while preserving write/nextRun. A generation failure records `failed` for its -whole group; terminate records `stopped`; generation or `post_tools` abort records `cancelled` and -creates no normal successor. Failure/terminate may place safe writes but do not consume queued -inputs that require a successor. Exactly one live generation or `post_tools` owns an active group, -and ownership transfers in the same commit that settles the previous owner. +Placement appends the entry, removes the list element and writes `placed` in one commit; a `write` +is placed and `done` in that same commit, so `placed` is never observable for it. Only the +generation and `post_tools` transition a group: a final answer records `done` with the answer's +entry id; the harness giving up records `unanswered` with reason `failed`; a tool's `terminate` +records `terminated`; an abort of the run, or `abortInput` on a queued item, records `aborted`. +Foreground abort withdraws queued steer and followUp while preserving write and nextRun. Failure +and terminate may place safe writes but do not consume queued inputs that require a successor. +Exactly one live generation or `post_tools` owns an active group, and ownership transfers in the +same commit that settles the previous owner. The list is stored as append/remove/clear operations, not as a rewritten array. Inbox watch events carry the same operations; an idle same-commit append/remove emits none. Memory and SQLite may discard a removed sticky element; JSONL retains its append record, so any payload later copied into a transcript entry appears twice on disk, including an idle acceptance. Once an unplaced item is -cancelled, its draft is no longer queryable; `inputResult` retains only its terminal status and +withdrawn, its draft is no longer queryable; `inputResult` retains only its terminal status and optional request id. ### 8.2 Generation @@ -1342,7 +1347,7 @@ outcome: deferred { status deferred, handle } → execute again: poll with sleeps until final retryable { status retry_wait, attempt+1, notBefore } → execute again: sleep, then stream overflow { settle failed(overflow); create collapse C; C's settlement creates G' } - failure { retain partial/error outcome for display if useful; resolve inputs failed; + failure { retain partial/error outcome for display if useful; resolve inputs unanswered(failed); settle failed; no tools/results } aborted provider returns in-band: attempt domain failure/retry commit; durable mark present → TaskCancelled; execute unwinds; driver calls abort below @@ -1372,7 +1377,7 @@ recover streaming: frames in scratch → retain/publish the partial for display recover deferred: execute again; the handle is in state abort: fresh invocation after execute/recover returned: read committed scratch; optionally retain partial for display, excluded from requests; - in one commit mark owned input results cancelled, record known usage, settle aborted; + in one commit resolve owned inputs unanswered(aborted), record known usage, settle aborted; no tool tasks/results; settlement retires scratch ``` @@ -1387,7 +1392,8 @@ way through the sink's `usage`. Persistence and invariant failures are not provi ```text tool execute: - check the call was offered by its generation; validate arguments + resolve the tool in the registry (the loadout may have changed since the turn started); + validate the model's arguments against its schema before_tool → allow(args) | block(reason, terminate?) (a human approval waits inside the hook) block / invalid / unknown tool → own error result, no invocation { status running; effective args; replay policy } @@ -1408,7 +1414,8 @@ abort: previous invocation already returned; read durable scratch and cleanup post_tools is the code in §5.5: read the tool tasks, stop on terminate, write the handoff if one was requested, place writes and steering, carry the extended input group into the next generation, -and settle. Its abort resolves its input group as cancelled and settles with no successor. +and settle. Its abort resolves its input group as `unanswered` with reason `aborted` and settles +with no successor. **Blocking budget.** A tool call may not block a turn indefinitely. Tools that run processes or child conversations create a job (or a conversation) first and wait on it with the budget; if the @@ -1498,7 +1505,7 @@ run: { create child conversation (this task owns it); initial values; accept abort: mark child's foreground tasks only, preserve its queues; write own error result and settle spawn: the same creation commit; settle at once with the child's id -send: child.accept(text, undefined, call) → queued if the child is busy +send: child.accept({ input: text }, call) → queued if the child is busy status: the child's tail and live tasks wait: await child.drive(call); child.result(lastInputId, call); settle (recover: drive again) stop: child.abort(call) @@ -1640,19 +1647,18 @@ listeners are not hooks: they observe, never decide, and never await a commit on interface ConversationHandle { readonly id: Id; snapshot(call: Call): Promise; - accept(input, options: { requestId?: string } | undefined, call: Call): Promise<{ inputId: Id }>; - prompt(input, options: { requestId?: string } | undefined, call: Call): Promise; + accept(options: { input: UserInput; requestId?: string; + whenBusy?: "followUp" | "steer" | "reject" }, call: Call): Promise<{ inputId: Id }>; + prompt(options: { input: UserInput; requestId?: string; + whenBusy?: "followUp" | "steer" | "reject" }, call: Call): Promise; result(inputId: Id, call: Call): Promise; drive(call: Call): Promise<"idle" | "closed">; - steer(input, call: Call): Promise<{ inputId: Id }>; - followUp(input, call: Call): Promise<{ inputId: Id }>; - nextRun(input, call: Call): Promise<{ inputId: Id }>; - write(kind: EntryKind, input: EntryInput, call: Call): Promise<{ inputId: Id }>; - cancelQueued(inputId: Id, call: Call): Promise<"cancelled" | "not_found">; + queueInput(input: QueuedInput, call: Call): Promise<{ inputId: Id }>; // §8.1 + abortInput(inputId: Id, call: Call): Promise<"aborted" | "not_found">; abort(call: Call): Promise; - collapse(options: { instructions? } | undefined, call: Call): Promise; - reset(handoff: string | undefined, call: Call): Promise; - fork(at: Id | "start", options: { abort?: boolean; values? } | undefined, call: Call): Promise; + collapse(options: { instructions?: string } | undefined, call: Call): Promise; + reset(options: { handoff?: string } | undefined, call: Call): Promise; + fork(options: { at: Id | "start"; abort?: boolean; values? }, call: Call): Promise; spawn(options, call: Call): Promise; value(addr) / list(addr) // async reads/writes take a final Call config(kind: TaskKind): { get(call: Call): Promise>; set(partial: Partial>, call: Call): Promise }; @@ -1683,12 +1689,13 @@ interface Harness { ``` `Call` is defined in §6.5. `call.abortSignal` cancels the drive caller's wait, not the work. -`fork(at, { abort: true }, call)` marks the source's foreground set in the same commit that creates the fork, for "go back to that point"; which +`fork({ at, abort: true }, call)` marks the source's foreground set in the same commit that creates the fork, for "go back to that point"; which conversation a UI treats as current is the UI's business. `prompt` returns the answer entry only -when its explicit input result is `done`; failed, cancelled, stopped or merely placed input returns -`undefined`. `result(inputId)` is one sticky-value point read, never a transcript scan. After an -uncertain remote response, `acceptance(requestId)` recovers the conversation/input identity; the -caller then reads its result or retries a create and handles `RequestAlreadyAccepted`. +when its input's result is `done` with an `answer`; `unanswered`, or input still `placed` when the +drive returned, gives `undefined`. `result(inputId, call)` is one sticky-value point read, never a +transcript scan. After an uncertain remote response, `acceptance(requestId, call)` recovers the +conversation and input identity, or the caller simply retries with the same request key and gets +the same `inputId` back (§3.4). ### 9.2 Commits @@ -1943,7 +1950,7 @@ interface ConversationView { readonly entries: readonly Entry[]; // the last `tail` entries; older ones via entries(id, { before }) readonly context: readonly Id[]; // the derived context, as ids readonly tasks: readonly Task[]; // live tasks, typed by kind (retry attempt, deferred handle, ToolOutputState ... in state) - readonly inbox: readonly Element[]; + readonly inbox: readonly Element[]; readonly values: ReadonlyMap; // every value the registered kinds declare in config, plus any asked for readonly previews: ReadonlyMap; // per live task: the kind's tracked preview (§5.2) readonly faulted: boolean; @@ -1951,7 +1958,7 @@ interface ConversationView { } type InboxOp = - | { readonly type: "append"; readonly item: Element } + | { readonly type: "append"; readonly item: Element } | { readonly type: "remove"; readonly id: Id } | { readonly type: "clear" }; @@ -2061,7 +2068,7 @@ const h = await Harness.open(storage, options, call); const c = await h.root(call); const w = await h.watch(c.id, { tail: 100 }, call); render(w.view); w.start(e => render(w.view, e)); -const answer = await c.prompt("Inspect the parser", undefined, call); +const answer = await c.prompt({ input: "Inspect the parser" }, call); const driving = c.drive(call); await c.abort(call); await driving; // durable intent, then cleanup @@ -2070,8 +2077,8 @@ const child = await c.spawn({ prompt: "Inspect only tests", context: "fresh", await h.drive(call); // drives the child too console.log(await h.conversation(child, call)); -const alt = await c.fork(answer.id, undefined, call); -await alt.prompt("Try a different implementation", undefined, call); // source remains untouched +const alt = await c.fork({ at: answer.id }, call); +await alt.prompt({ input: "Try a different implementation" }, call); // source remains untouched w.unsubscribe(); await h.close(call); ``` @@ -2089,7 +2096,7 @@ h.kinds.generation; h.kinds.tool; h.kinds.postTools; h.kinds.collapse; h.kin The built-in entry kinds (`user`, `assistant`, `tool_result`, `system`, `notice`, `summary`, `handoff`, `reset`) and task kinds (`generation`, `tool`, `post_tools`, `collapse`, `job`) are -registered by `open` itself, because `accept`, `prompt`, `steer` and `collapse` need them to write +registered by `open` itself, because `accept`, `prompt`, `queueInput` and `collapse` need them to write and type new records. Reading context does not need entry kinds. A task-kind replacement is registered under the built-in's name and must understand its persisted statuses and keep its hook names, so existing handlers keep working; a wrapper that delegates to the original is the usual @@ -2140,7 +2147,7 @@ source cutoffs, sticky state untouched by historical reads. | Two overlapping drives | one execution per task | | A drive caller cancels its wait | other callers and tasks unaffected | | Summary lands after a competing head/reset | rejected as stale; intervening edits do not stale it | -| Cancel vs land of the same inbox item | one wins on the line; one terminal input result | +| Withdraw vs land of the same inbox item | one wins on the line; one terminal input result | | Abort vs generation/post_tools group transfer | every active input resolves once; no unmarked owner escapes | | Several inputs share one generation | every result points to the same final answer entry | | Overflow retry while collapse runs | no live task waits on the collapse |