mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(agent): simplify input results and unify queued input
This commit is contained in:
parent
1b5aa80aec
commit
378977cd3d
2 changed files with 150 additions and 125 deletions
|
|
@ -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);
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue