mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(agent): make pico input attribution explicit
This commit is contained in:
parent
05c6229813
commit
400d6905ce
3 changed files with 223 additions and 106 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue