diff --git a/packages/agent/docs/pico/pico-usage-guide.md b/packages/agent/docs/pico/pico-usage-guide.md index 81596aba1..db5e420e1 100644 --- a/packages/agent/docs/pico/pico-usage-guide.md +++ b/packages/agent/docs/pico/pico-usage-guide.md @@ -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 } + | { 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[] } + | { 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 | diff --git a/packages/agent/docs/pico/pico-v3.md b/packages/agent/docs/pico/pico-v3.md index fe9558329..c3d1fd77c 100644 --- a/packages/agent/docs/pico/pico-v3.md +++ b/packages/agent/docs/pico/pico-v3.md @@ -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> 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; - accept(input, options?: { requestId? }): Promise<{ entryId: number }>; - prompt(input, options?): Promise; // accept + drive + answerTo(entryId) - answerTo(entryId: Id): Promise; // last assistant entry after it and before the next user entry + accept(input, options?: { requestId?: string }): Promise<{ inputId: Id }>; + prompt(input, options?): Promise; // accept + drive + result(inputId) + result(inputId: Id): Promise; 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(kind: EntryKind, input: EntryInput): Promise<{ inputId: Id }>; + cancelQueued(inputId: Id): Promise<"cancelled" | "not_found">; abort(): Promise; collapse(options?: { instructions? }): Promise; // collapse task id reset(handoff?): Promise; @@ -1434,6 +1487,7 @@ interface Harness { root(): Promise; conversation(id): Promise; conversations(q?): Promise>; // 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 } + | { 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[] } + | { 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 | diff --git a/packages/agent/docs/pico/pico-work.md b/packages/agent/docs/pico/pico-work.md index c2c1c1c6f..1e4347537 100644 --- a/packages/agent/docs/pico/pico-work.md +++ b/packages/agent/docs/pico/pico-work.md @@ -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