docs(agent): make pico input attribution explicit

This commit is contained in:
Mario Zechner 2026-09-09 23:29:53 +02:00
parent 05c6229813
commit 400d6905ce
3 changed files with 223 additions and 106 deletions

View file

@ -433,23 +433,46 @@ Nothing to do in any of them.
### prompt, accept, drive
`prompt` is three things: accept the input, drive the conversation until it is idle, read the
answer to that input.
explicit result for that input.
```typescript
const answer = await c.prompt('Inspect the parser');
// AssistantEntry | undefined (the run ended without an answer: aborted, failed)
```
The pieces are available separately, and they work across retries. `accept` is idempotent by
`requestId`, and `answerTo` finds the answer to an input however far the conversation has moved
since, so a client that lost the reply can ask again:
The pieces are available separately. `inputId` is always the id of the accepted `pi.inbox` list
element, even when idle acceptance places and removes it in the same commit. Generation and
`post_tools` carry input ids explicitly, so result lookup never scans the transcript:
```typescript
const { entryId } = await c.accept('Inspect the parser', { requestId: 'req-42' });
const { inputId } = await c.accept('Inspect the parser', { requestId: 'req-42' });
const outcome = await c.drive(); // 'idle' | 'closed'
const answer = await c.answerTo(entryId); // last assistant entry after it, before the next user entry
const result = await c.result(inputId); // one sticky-value point read
const answer = result?.status === 'done'
? await h.getEntry(assistantKind, result.resultEntryId)
: 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:
```typescript
const existing = await h.acceptance('req-42');
if (existing) {
const conversation = await h.conversation(existing.conversationId);
return {
inputId: existing.inputId,
result: await conversation?.result(existing.inputId),
};
}
return c.accept(input, { requestId: 'req-42' });
```
A concurrent duplicate `accept` rejects as `RequestAlreadyAccepted` and identifies the first
receipt. Request ids are optional; there is no payload comparison or silent replay.
`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
@ -458,20 +481,30 @@ is live.
### Input While Busy
While a turn runs, new input is queued rather than refused. Which mode decides where it lands and
whether it asks for another turn:
While a turn runs, new input is queued rather than refused. The mode determines the boundary and
input-group behavior:
```typescript
await c.steer('Focus on the tokenizer first'); // next tool boundary or final answer; requests a turn
await c.followUp('Then write the tests'); // after the final answer; requests a turn
await c.nextRun('Remind me to commit'); // only at the next idle accept
await c.write('user stepped away'); // next boundary; no turn requested
const steer = await c.steer('Focus on the tokenizer first'); // post_tools: joins current group;
// final answer: starts next group
const follow = await c.followUp('Then write the tests'); // after final answer; starts next group
const next = await c.nextRun('Remind me to commit'); // waits for the next explicit idle accept
const write = await c.write(noteKind, { // next safe boundary; no generation
data: { text: 'user stepped away' },
});
```
The queue is visible in the view (`view.inbox`) and any item can be withdrawn until it lands:
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
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:
```typescript
await c.cancelQueued(itemId); // 'cancelled' | 'not_found'
await c.cancelQueued(steer.inputId); // 'cancelled' | 'not_found'
```
### Aborting
@ -481,11 +514,12 @@ await c.abort(); // every live foreground task of this conversation, an
await h.abortTask(id); // one background task: a job, a schedule
```
An abort is a durable mark on the task, then cleanup: a generation may retain its partial for the
UI but creates no tool tasks/results and is excluded from later requests; tool tasks that already
exist write their own error results; a subagent tool aborts its child. If the process dies between
the mark and cleanup, the next one finishes it. Queued `steer` and `followUp` are dropped by `abort`;
`write` and `nextRun` stay.
An abort is a durable mark on the task, then cleanup. The live generation or `post_tools` owns the
active input ids and resolves all of them as cancelled without a normal successor. A generation may
retain its partial for the UI but creates no tool tasks/results and is excluded from later requests;
tool tasks that already exist write their own error results; a subagent tool aborts its child. If
the process dies between the mark and cleanup, the next one finishes it. Queued `steer` and
`followUp` are removed and marked cancelled; `write` and `nextRun` stay queued.
## Watching
@ -523,6 +557,11 @@ shape for its process output.
### Events
```typescript
type InboxOp =
| { type: 'append'; item: Element<InboxItem> }
| { type: 'remove'; id: Id }
| { type: 'clear' };
type ConversationEvent =
| { type: 'entry'; entry: Entry }
| { type: 'task_start'; task: Task }
@ -530,14 +569,16 @@ type ConversationEvent =
| { type: 'task_end'; task: Task }
| { type: 'task_output'; task: Id; ops: DeltaOp[] } // already applied to view.previews
| { type: 'value'; addr: Address; value: JsonValue | undefined }
| { type: 'inbox'; items: Element<InboxItem>[] }
| { type: 'inbox'; ops: InboxOp[] }
| { type: 'context'; ids: Id[] } // a head or edit changed derived context
| { type: 'fault'; error: unknown }
| { type: 'closed' };
```
The view is authoritative and the event is a wake-up: when the listener runs, `w.view` has already
been folded. A renderer may ignore the event payload entirely and be correct. Because every piece
been folded. Inbox operations are combined per commit; an idle acceptance's append and immediate
remove emits no inbox event. A renderer may ignore the event payload entirely and be correct.
Because every piece
of work is a task of a known kind, four task events cover what used to need a name per case:
| you want to know | look at |

View file

@ -327,19 +327,23 @@ stop using this handle, reopen, recover the last complete batch and its `lastSeq
from that. No observer ever sees half a commit. An effect must not treat an uncertain write as
permission to redo the same external action with new ids.
### 3.4 Request keys are ordinary state
### 3.4 Request keys name acceptances
A caller's request id is a session value: `accept(input, requestId)` looks the key up inside the
commit; if present it returns the existing acceptance, otherwise it creates the input and records
the key in the same batch. Because the mapping is session state, it may follow and reference the
new entry or inbox element:
Every accepted input has an `inputId`, the id of its `pi.inbox` list element (§8.1). A caller may
also supply an opaque request id. Acceptance atomically stores a session value from that key to the
new identity:
```text
400 user entry or inbox element
401 request["req-42"] = 400
400 append pi.inbox item inputId = 400
401 inputResult[400] = queued or running
402 request["req-42"] = { conversationId, inputId:400 }
```
A lost reply plus a retry yields the same accepted id. Reusing a key for different input rejects.
`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.
## 4. State
@ -385,7 +389,7 @@ A rewindable history reads as of an entry (§3.2): a list appended at 40 and 50,
appended at 70 reads `[A, B]` at entry 55, `[]` at 65, `[C]` at 75. A remove hides one element the
same way a clear hides all of them. Clear hides; it doesn't erase
what an earlier fork needs. Deleting a rewindable value records absence. Sticky state exposes only
its current contents.
its current contents, so a removed sticky list element may be discarded physically.
### 4.3 Initialization is explicit; forks inherit
@ -621,7 +625,10 @@ await ctx.commit(tx => {
const assistant = tx.entry(assistantKind, { model: [message] }); // calls [A, B]
const tools = message.calls.map(call =>
tx.task(toolKind, { state: { call, assistant } }));
tx.task(postToolsKind, { after: tools, state: { assistant, tools } });
tx.task(postToolsKind, {
after: tools,
state: { assistant, tools, inputs: task.state.inputs },
});
tx.settle(task, { assistant });
});
```
@ -635,8 +642,14 @@ async execute(task, ctx) {
await ctx.commit(tx => {
const tools = tx.getTasks(toolKind, task.state.tools); // Map<id, Task<ToolState>>
const outcomes = [...tools.values()].map(t => t.state.output); // ToolOutputState
if (outcomes.some(o => o.terminate) || tx.getTask(task.id)!.abort)
return tx.settle(task, { stopped: true }); // outcome lands, no successor
if (tx.getTask(task.id)!.abort) {
resolveInputs(tx, task.state.inputs, "cancelled");
return tx.settle(task, { cancelled: true }); // no successor
}
if (outcomes.some(o => o.terminate)) {
resolveInputs(tx, task.state.inputs, "stopped");
return tx.settle(task, { stopped: true }); // no successor
}
const added = outcomes.flatMap(o => o.addedTools ?? []);
const selectedTools = added.length ? [...current, ...added] : current;
if (added.length) tx.value(generationKind.config.selectedTools).set(selectedTools);
@ -644,8 +657,8 @@ async execute(task, ctx) {
if (handoff) tx.entry(handoffKind, {
data: { text: handoff.handoff }, model: [handoffMessage(handoff.handoff)], head: "self",
});
landSteering(tx, task.conversationId); // §8.1
tx.task(generationKind, { state: nextGeneration(task, { selectedTools }) });
const inputs = landPostToolsInbox(tx, task.conversationId, task.state.inputs); // writes + steer (§8.1)
tx.task(generationKind, { state: nextGeneration(task, { selectedTools, inputs }) });
tx.settle(task, {});
});
}
@ -1001,9 +1014,10 @@ committed state plus earlier writes in the batch (kinds validate status transiti
enforces ownership, dependencies, exchange rules and admission; nobody validates payload shapes:
stored objects are trusted, and schema validation belongs at wire boundaries). For an entry with
`head`, validation requires the stored id to be visible and at or after the previous fork-visible
head's stored boundary. Stored edit targets must be earlier visible entries. No entry-kind code runs.
Storage checks scope, sequences and structure, prepares only touched data, persists atomically,
publishes. A commit with no writes writes nothing.
head's stored boundary. Stored edit targets must be earlier visible entries. Input-result transitions
are validated, terminal results cannot change, and consuming/cancelling an inbox element writes its
result in the same batch. No entry-kind code runs. Storage checks scope, sequences and structure,
prepares only touched data, persists atomically, publishes. A commit with no writes writes nothing.
### 7.4 Memory and JSONL
@ -1065,42 +1079,75 @@ outside the line, and their decisions are re-validated inside it.
### 8.1 Accepting input
A conversation is busy when its foreground set is not empty.
The inbox 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:
```text
idle accept: { append user entry; create generation, foreground }
busy accept: { append { message, mode } to the inbox }
```ts
interface AcceptedEntryDraft extends EntryDraft { readonly kind: string }
type InputMode = "write" | "steer" | "followUp" | "nextRun";
interface InboxItem {
readonly mode: InputMode;
readonly entry: AcceptedEntryDraft;
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 };
```
The inbox is a conversation-scoped sticky list, `pi.inbox`. A queued item is the message itself;
nothing is written to the transcript until it lands. Three places dequeue: post_tools after a tool
exchange, the generation when it settles a final answer, and `accept` when the conversation is
idle. Each reads the list, takes the items its mode table allows, appends a `user` entry per item,
removes them, and creates the next generation, all in one commit. `cancelQueued(elementId)` is a
remove. Nothing else exists.
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:
| Mode | Lands at | Requests a generation |
| Mode | Placement | Input-group effect |
|---|---|---|
| write | post_tools or a final answer | no |
| steer | post_tools or a final answer | yes |
| followUp | a final answer | yes |
| nextRun | an idle accept only | yes |
| write | next post_tools or final boundary | none; terminal result is `placed` |
| 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 |
Landing selects either everything eligible or the oldest item per mode, by configuration, in
admission order. Selection, landing, and the generation it triggers share one commit. Foreground
abort cancels queued steer and followUp; write and nextRun stay.
A generation and its `post_tools` carry `inputs: Id[]`. Generation-with-calls creates the tools and
exactly one `post_tools` with the same group in its settlement commit. `post_tools` places writes,
places selected steering in admission order, extends the group with those steer ids, creates the
continuation generation and settles. At a final answer, the generation resolves every current
input to that answer, places writes, then places selected steer/followUp items into a new group and
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.
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
optional request id.
### 8.2 Generation
```text
create: capture the kind's declared config (model, thinking, selected tools, profile; §5.2,
rewindable values) and provider options and retry policy, so a retry uses what the
create: capture inputs, the kind's declared config (model, thinking, selected tools, profile;
§5.2, rewindable values), provider options and retry policy, so a retry uses what the
attempt used
execute: if a collapse is needed by threshold: { create collapse C; create generation G' after:[C]; settle }
desired = system_instructions hook, per conversation: keyed sections (the host's identity,
cwd, skills, context files; a plugin's plan_mode) merged across handlers and
rendered in declared order, plus tool definitions selected from the catalogue;
none of the inputs is stored
none of these host prompt sources is stored as config
sent = the newest baseline `system` entry in the context plus the deltas after it;
deltas before it (kept by a compaction) are subsumed and ignored, by the fold
and by request projection alike
@ -1111,14 +1158,16 @@ execute: if a collapse is needed by threshold: { create collapse C; create g
project the context; before_request; re-check status and mark
stream; frames to scratch
outcome:
calls { assistant; tools; post_tools; settle done }
final { assistant; resolve or continue (§8.3); settle done }
calls { assistant; tools; post_tools carrying inputs; settle done }
final { assistant; resolve every current input; place next-group inbox items and successor if any;
settle done }
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; settle failed; no tools/results }
aborted stream ends with stopReason "aborted": { retain partial for display if useful; settle aborted;
no tool tasks/results }
failure { retain partial/error outcome for display if useful; resolve inputs failed;
settle failed; no tools/results }
aborted stream ends with stopReason "aborted": { retain partial for display if useful;
resolve inputs cancelled; settle aborted; no tool tasks/results }
```
**Prompt and tools.** Two things the lane harness fuses are kept apart. What the client wants is
@ -1175,8 +1224,8 @@ abort: join own execution; drain scratch; own aborted error result; settle
```
post_tools is the code in §5.5: read the tool tasks, stop on terminate, write the handoff if one
was requested, land steering, create the next generation, settle. Its abort settles it with no
successor.
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.
**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
@ -1203,9 +1252,10 @@ the promise and retargets the sink into the job's scratch; that path is best-eff
`new_context` is a tool that calls `out.handoff(message)`; the reset happens here, after the exchange
is complete, never inside the tool, because a head landing mid-exchange would cut it in half. Conflicting handoffs reject rather than pick an order.
A final answer's continuation is decided in the generation's settlement: `on_yield` may request an
explicit continuation (a user entry and a generation); eligible followUp input lands and starts the
next turn; an owned conversation's final answer settles nothing by itself, its owner is a tool
A final answer's continuation is decided in the generation's settlement. `on_yield` may request an
explicit continuation that keeps the current input group. Otherwise the answer resolves that group,
then eligible steer/followUp items are placed into a new group and its generation. An owned
conversation's final answer still resolves its own inputs; its owner tool observes that result while
driving it (§8.5). An idle conversation whose tail is a user entry creates no work by inference.
### 8.4 Collapse
@ -1245,16 +1295,16 @@ type SubagentCommand =
```
```text
run: { create child conversation (this task owns it); initial values; prompt; first generation }
run: { create child conversation (this task owns it); initial values; accept prompt → inputId; first generation }
await child.drive() // blocked on the world; the tool stays inflight
child.answerTo(prompt entry); { result entry; settle }
child.result(inputId); { result entry; settle }
recover: the child exists; drive it again
abort: the mark on this tool marks the child's foreground set through ownership; error result
spawn: the same creation commit; settle at once with the child's id
send: child.accept(text) → queued if the child is busy
status: the child's tail and live tasks
wait: await child.drive(); child.answerTo(the last sent prompt); settle (recover: drive again)
wait: await child.drive(); child.result(the last sent inputId); settle (recover: drive again)
stop: child.abort()
```
@ -1412,12 +1462,15 @@ listeners are not hooks: they observe, never decide, and never await a commit on
interface ConversationHandle {
readonly id: Id;
snapshot(): Promise<Conversation>;
accept(input, options?: { requestId? }): Promise<{ entryId: number }>;
prompt(input, options?): Promise<AssistantEntry | undefined>; // accept + drive + answerTo(entryId)
answerTo(entryId: Id): Promise<AssistantEntry | undefined>; // last assistant entry after it and before the next user entry
accept(input, options?: { requestId?: string }): Promise<{ inputId: Id }>;
prompt(input, options?): Promise<AssistantEntry | undefined>; // accept + drive + result(inputId)
result(inputId: Id): Promise<InputResult | undefined>;
drive(options?: { signal? }): Promise<"idle" | "closed">;
steer(input); followUp(input); nextRun(input); write(message);
cancelQueued(elementId): Promise<"cancelled" | "not_found">;
steer(input): Promise<{ inputId: Id }>;
followUp(input): Promise<{ inputId: Id }>;
nextRun(input): Promise<{ inputId: Id }>;
write<E extends Entry>(kind: EntryKind<E>, input: EntryInput<E>): Promise<{ inputId: Id }>;
cancelQueued(inputId: Id): Promise<"cancelled" | "not_found">;
abort(): Promise<void>;
collapse(options?: { instructions? }): Promise<number>; // collapse task id
reset(handoff?): Promise<void>;
@ -1434,6 +1487,7 @@ interface Harness {
root(): Promise<ConversationHandle>;
conversation(id): Promise<ConversationHandle | undefined>;
conversations(q?): Promise<Page<Conversation>>; // independent ones: owner === undefined
acceptance(requestId: string): Promise<{ requestId: string; conversationId: Id; inputId: Id } | undefined>;
inspect(): Promise<{ start: Task[]; inflight: Task[] }>;
drive(options?): Promise<"idle" | "closed">;
getEntry(id); getEntry(kind, id); getEntries(ids); getEntries(kind, ids); entries(conversationId, page);
@ -1449,11 +1503,11 @@ interface Harness {
`drive`'s signal cancels the caller's wait, not the work. `fork({ abort: true })` 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 to this input, or
`undefined` if the run ended without one. `answerTo(entryId)` is the read behind it: the last
assistant entry after the input and before the next user entry (or the tail), one bounded range
query; so a retried `accept` with the same `requestId` followed by `drive` and `answerTo` yields
the same answer however far the conversation has moved since. Nothing else maps inputs to results.
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`.
### 9.2 Commits
@ -1693,6 +1747,11 @@ interface ConversationView {
readonly readAt: Id;
}
type InboxOp =
| { readonly type: "append"; readonly item: Element<InboxItem> }
| { readonly type: "remove"; readonly id: Id }
| { readonly type: "clear" };
type ConversationEvent =
| { type: "entry"; entry: Entry }
| { type: "task_start"; task: Task }
@ -1700,7 +1759,7 @@ type ConversationEvent =
| { type: "task_end"; task: Task }
| { type: "task_output"; task: Id; ops: readonly DeltaOp[] } // ops on the task's preview; applied to view.previews
| { type: "value"; addr: Address; value: JsonValue | undefined }
| { type: "inbox"; items: readonly Element<InboxItem>[] }
| { type: "inbox"; ops: readonly InboxOp[] }
| { type: "context"; ids: readonly Id[] } // a head or edit changed derived context
| { type: "fault"; error: unknown } | { type: "closed" };
@ -1739,7 +1798,9 @@ listener neither deadlocks nor refolds stale state. Buffering is bounded; a lagg
Events are proportional to their change, and the view fold is a generic reducer,
`applyEvent(view, event)`, exported by the harness package and needing no kinds: append the entry,
upsert the task, apply the ops to `previews[task]`, set the value. The harness maintains `w.view`
upsert the task, apply task-output ops to `previews[task]`, apply inbox ops, set the value. Inbox
operations are folded across one commit before delivery, so an idle acceptance's same-commit append
and remove emits no inbox event. The harness maintains `w.view`
with it, and a UI in another process runs the same reducer on the same events; nothing is
re-diffed for the wire:
@ -1850,7 +1911,7 @@ source cutoffs, sticky state untouched by historical reads.
| Scenario | Required |
|---|---|
| Persistence paused after construction | readers see the old complete state |
| Commit durable, reply lost | reopen sees the batch; keyed retry returns the same id |
| Accept commit durable, reply lost | `acceptance(requestId)` returns the original conversation/input id |
| Main and scratch writes in one batch | reject, nothing written |
| Torn final JSONL line | only that line discarded |
| Malformed complete line | open fails; no silent truncation |
@ -1862,7 +1923,9 @@ 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; the loser reports it |
| Cancel 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 |
| Watch registration races a commit | base includes it or the stream delivers it |

View file

@ -13,8 +13,9 @@ shows the surface each package has to end up with.
`Id`, `EntryIdentity`, `EntryBase`, the composable `EntryData` / `ModelProjection` /
`ContextHead` / `ContextEdits` facets, `Entry`, `EntryKind`, `EntryInput`, `ContextEdit`, `Task`,
`TaskRole`, stored task roles, `Conversation`, `Address`/`Value`/`List`/`Scope`,
`Write`/`CommitBatch`, `Page`/`Cursor` and the query shapes (§2, §4.1, §5.1, §7.2–7.3). No code.
`TaskRole`, stored task roles, `Conversation`, `InboxItem`, `InputResult`, `InboxOp`, acceptance
receipts, `Address`/`Value`/`List`/`Scope`, `Write`/`CommitBatch`, `Page`/`Cursor` and the query shapes
(§2, §4.1, §5.1, §7.2–7.3). No code.
Test: it compiles; fixtures cover entries with no data, no model, every individual facet and the
built-in facet combinations.
@ -94,8 +95,9 @@ new attempt clears its list.
`Harness.open` (built-in registries, `kinds` and `replace` options, kinds-set check, `inspect`,
`drive`, `close`, `shutdown`), `ConversationHandle` (`commit`, `value` / `list`, `config` /
`settings`, `fork` with `abort`, `abort`, `hooks.on` scoped with `subtree`), `abortTask`,
`conversations` with `parent` / independent filtering. No agent behaviour yet.
`settings`, `fork` with `abort`, `abort`, `hooks.on` scoped with `subtree`), `acceptance(requestId)`,
`result(inputId)`, `abortTask`, `conversations` with `parent` / independent filtering. No agent
behaviour yet.
Tests: open on empty vs existing storage; an unregistered entry kind is reported without scanning
entries and its stored model/head/edits still derive context; an unknown live task kind rejects;
@ -104,9 +106,10 @@ harness-wide ones, innermost last.
## 9. Generation kind
One stable generation task cycles pending → streaming → retry_wait / deferred → streaming until
done / failed / aborted on a faux provider; config capture (model, thinking, selected tools, profile,
budget); `system_instructions` with
One stable generation task carries `inputs: Id[]` and cycles pending → streaming → retry_wait /
deferred → streaming until done / failed / aborted on a faux provider; explicit terminal results for
its whole input group; config capture (model, thinking, selected tools, profile, budget);
`system_instructions` with
sections merged across handlers and the diff writing `system` data plus its materialized model
message; `before_request`,
`after_response`, `on_yield`; retry sleeps in execute; recover from frames; usage recorded per
@ -122,22 +125,29 @@ crash while streaming publishes the partial; retry budget exhausted → failed w
The tool kind with the sink (`ToolOutput`, `ToolOutputState`, limits enforced by the sink, `diag`,
`delegate`, `handoff`, `addTools`, `terminate`), tool-result entries with structured data plus their
materialized model message, `before_tool` (fail-closed) and `after_tool`,
replay policy on recover; post_tools with `after`, terminate / handoff / steer / next generation;
`accept` idle vs busy; `prompt` and `answerTo`.
replay policy on recover; post_tools with `after`, carried input groups, terminate / handoff / steer /
next generation; `accept` idle vs busy; `prompt`, `result` and request acceptance lookup.
Tests: parallel tools completing in either order; sequential via `after`; an aborted generation
creates no tool tasks/results while an aborted existing tool writes its own error result;
`new_context` resets after the exchange, never inside it; `addTools` writes the rewindable loadout
before any handoff/user entry in the settlement commit and appears in the next turn's `toolsAdded`;
a throwing tool → error result, `terminate` still honoured; truncation diag from the sink; `accept`
with the same `requestId` twice → same entry, `answerTo` finds it after further turns.
a throwing tool → error result, `terminate` still honoured; truncation diag from the sink; a lost
accept response is recovered through `acceptance(requestId)`; a duplicate create reports the first
receipt; results remain point-readable after further turns.
## 11. Inbox
`pi.inbox` as a conversation list, the modes, the three dequeue points, `cancelQueued`, abort
draining steer and followUp.
`pi.inbox` as a conversation sticky list whose element id is `inputId` and whose value holds mode,
complete entry draft and optional request id; append/remove/clear watch operations; queued/running
and terminal result values; the three placement points; carried generation/post_tools input groups;
`cancelQueued`; abort draining steer and followUp while preserving write and nextRun.
Tests: the modes table; cancel vs land in both orders; input queued during a collapse lands.
Tests: idle append/remove is one commit and emits no inbox event; busy image payload is one append
operation; the modes table; steer joins at post_tools but starts a group after a final answer;
followUp starts the next group; nextRun waits for idle accept; writes are placed without joining;
several inputs resolve to one answer; cancel/land and abort/group-transfer in both orders; queued and
placed crash recovery; cancelled unplaced payload is unavailable; input queued during collapse lands.
## 12. Collapse
@ -152,8 +162,8 @@ flowing.
## 13. Subagents
The `subagent` tool (`run`, `spawn`, `send`, `status`, `wait`, `stop`), ownership links, foreground
reach through live owners, `run`'s recover driving the child again, `spawn` initialization of
config, `answerTo` on the child.
reach through live owners, `run`'s recover driving the child again, `spawn` initialization of config,
explicit child input results.
Tests: restart in the middle of `run`; parent abort reaches a `run` child and spares a `spawn`
child; `stop`; nested children; the child's first turn writes its own baseline.
@ -185,8 +195,9 @@ equal to the live preview; a sliding tool tail → `t` + `a`.
`report` and `usage`, the usage ledger (`pi.usage` + totals).
Tests: the fold is correct (view after N events equals a fresh capture, randomized); head and edit
entries update derived context; all events of one commit delivered together; a thin-client reducer
over a recorded stream with no kinds loaded;
entries update derived context; inbox append/remove/clear operations update the view and same-commit
append/remove cancels; all events of one commit delivered together; a thin-client reducer over a
recorded stream with no kinds loaded;
lag → fault → resnapshot; `resnapshot` from inside the listener; usage totals equal the ledger
fold, failed and aborted attempts included.
@ -197,14 +208,16 @@ Append-only batches, replay on open, torn tail discarded, malformed line fails o
first or small, ops otherwise), scratch sidecars retired after the main-file settle.
Tests: conformance against memory (one mutation stream, identical query results); linear file
growth under repeated sets of a large value; crash between the settle write and the sidecar unlink.
growth under repeated sets of a large value; accepted payload plus placement has two JSONL copies,
including idle acceptance; crash between the settle write and the sidecar unlink.
## 18. SQLite storage
Tables and indexes of §7.5, nullable entry JSON columns for data/model/edits and an indexed integer
head boundary, scratch rows deleted in the settle transaction, storage version and `migrate`.
Tests: conformance three ways; a cold reopen decodes only live rows (count them); a version
Tests: conformance three ways; removed sticky inbox elements may be physically discarded while
input results remain point-readable; a cold reopen decodes only live rows (count them); a version
mismatch rejects.
## 19. Race matrix and telemetry