docs(agent): simplify input results and unify queued input

This commit is contained in:
badlogic 2026-09-10 21:08:28 +00:00
parent 1b5aa80aec
commit 378977cd3d
2 changed files with 150 additions and 125 deletions

View file

@ -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);
```

View file

@ -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<E>; readonly entry: EntryInput<E>; 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<E>` (§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<Conversation>;
accept(input, options: { requestId?: string } | undefined, call: Call): Promise<{ inputId: Id }>;
prompt(input, options: { requestId?: string } | undefined, call: Call): Promise<AssistantEntry | undefined>;
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<AssistantEntry | undefined>;
result(inputId: Id, call: Call): Promise<InputResult | undefined>;
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<E extends Entry>(kind: EntryKind<E>, input: EntryInput<E>, 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<void>;
collapse(options: { instructions? } | undefined, call: Call): Promise<Id>;
reset(handoff: string | undefined, call: Call): Promise<void>;
fork(at: Id | "start", options: { abort?: boolean; values? } | undefined, call: Call): Promise<ConversationHandle>;
collapse(options: { instructions?: string } | undefined, call: Call): Promise<Id>;
reset(options: { handoff?: string } | undefined, call: Call): Promise<void>;
fork(options: { at: Id | "start"; abort?: boolean; values? }, call: Call): Promise<ConversationHandle>;
spawn(options, call: Call): Promise<Id>;
value(addr) / list(addr) // async reads/writes take a final Call
config<C>(kind: TaskKind<unknown, HookPoints, C>): { get(call: Call): Promise<ConfigValues<C>>; set(partial: Partial<ConfigValues<C>>, call: Call): Promise<void> };
@ -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<InboxItem>[];
readonly inbox: readonly Element<QueuedInput>[];
readonly values: ReadonlyMap<Address, JsonValue>; // every value the registered kinds declare in config, plus any asked for
readonly previews: ReadonlyMap<Id, JsonValue>; // 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<InboxItem> }
| { readonly type: "append"; readonly item: Element<QueuedInput> }
| { 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 |