mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
773 lines
39 KiB
Markdown
773 lines
39 KiB
Markdown
# pico
|
|
|
|
Durable agent harness. One session file, any number of conversations, every piece of work is a task that
|
|
survives a crash, and a view a UI renders directly.
|
|
|
|
This guide shows how to use it. `pico-handoff-v2.md` is the implementation specification.
|
|
|
|
- [Mental model](#mental-model)
|
|
- [Quick start](#quick-start)
|
|
- [Opening, closing, recovery](#opening-closing-recovery)
|
|
- [Sending input](#sending-input)
|
|
- [Configuration](#configuration)
|
|
- [System prompt](#system-prompt)
|
|
- [Watching](#watching)
|
|
- [Forks, compaction, reset](#forks-compaction-reset)
|
|
- [Plugin state](#plugin-state)
|
|
- [Writing tools](#writing-tools)
|
|
- [Hooks](#hooks)
|
|
- [Writing entry kinds](#writing-entry-kinds)
|
|
- [Writing task kinds](#writing-task-kinds)
|
|
- [Subagents and jobs](#subagents-and-jobs)
|
|
- [Storage backends](#storage-backends)
|
|
|
|
## Mental model
|
|
|
|
A **session** holds **conversations**. A conversation has:
|
|
|
|
- a **transcript**: append-only immutable **entries** (user, assistant, tool result, summary, notice, ...);
|
|
- **tasks**: units of work (generate, run a tool, compact, run a process). Each survives a crash;
|
|
- **state**: keyed values and lists (model in use, plan mode, whatever a plugin needs).
|
|
|
|
The model **context** is derived from entries, not stored: the newest head entry says where context starts,
|
|
edits omit or replace earlier messages. Compaction appends a summary with a head; nothing is rewritten.
|
|
|
|
All writes go through **commits**: one callback, one atomic batch. Tasks run concurrently, commits serialize.
|
|
|
|
Every async call takes a Chord `Context` last (`ctx`). It carries cancellation and telemetry. Cancelling a
|
|
wait removes the waiter; it never cancels durable work.
|
|
|
|
## Quick start
|
|
|
|
```typescript
|
|
import { Harness, JsonlStorage, systemSections } from '@earendil-works/pi-agent/pico';
|
|
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
|
|
import { BACKGROUND_CONTEXT as ctx } from '@earendil-works/chord/context';
|
|
|
|
const storage = await JsonlStorage.open('./session.jsonl', ctx);
|
|
const h = await Harness.open(storage, {
|
|
models: builtinModels(),
|
|
// no `tools`: the ordinary tool API is not settled yet (see Writing tools); this is a tool-free agent
|
|
root: { // applied once, when the session file is new
|
|
config: {
|
|
model: { provider: 'anthropic', modelId: 'claude-opus-5' },
|
|
thinkingLevel: 'high',
|
|
},
|
|
},
|
|
}, ctx);
|
|
|
|
const c = await h.root(ctx);
|
|
|
|
// System prompt: edit typed sections before each request. Stored when it changes, replayed after a restart.
|
|
// Registered on this conversation only: subagents get their own identity (see Subagents).
|
|
c.hook.systemInstructions(({ sections }) => {
|
|
sections.set(systemSections.identity, 'You are a careful engineer working in this repository.');
|
|
sections.set(systemSections.environment, { cwd: process.cwd() });
|
|
});
|
|
|
|
// Watch: `w.view` is always current; each delivery is one commit's events.
|
|
const w = await c.watch(ctx);
|
|
w.start((d) => {
|
|
if (d.type !== 'commit') return;
|
|
for (const e of d.events) {
|
|
if (e.type === 'task_output') process.stdout.write(renderStreaming(w.view.taskOutputs));
|
|
if (e.type === 'entry') console.log(`\n[${e.entry.kind} ${e.entry.id}]`);
|
|
if (e.type === 'config' && e.key === 'model') statusLine.set(e.value); // typed: ModelRef | undefined
|
|
}
|
|
});
|
|
|
|
// Send: durable, returns at once. Wait for this input's answer.
|
|
const input = await c.send({ requestId: 'cli-1', content: 'List the public API of the parser' }, ctx);
|
|
const result = await input.wait(ctx);
|
|
if (result.status === 'done' && result.answer) console.log(result.answer.message);
|
|
|
|
// Change settings any time; the next request uses them.
|
|
await c.config.model.set({ provider: 'openai', modelId: 'gpt-5.6' }, ctx);
|
|
|
|
w.unsubscribe();
|
|
await h.close(ctx);
|
|
```
|
|
|
|
Kill it mid-run and run it again: the second run recovers the in-flight generation (or job) and continues;
|
|
tools will recover the same way once their API lands.
|
|
|
|
## Opening, closing, recovery
|
|
|
|
```typescript
|
|
const h = await Harness.open(storage, options, ctx); // inert: nothing runs yet
|
|
await h.resume(ctx); // start/recover every eligible task
|
|
```
|
|
|
|
`send`, `wait`, `waitForIdle`, `collapse` and abort operations call `resume` for you. Reads, inspection and
|
|
watch do not. After a crash, `open` + `resume` is the whole recovery procedure:
|
|
|
|
| was running | on resume |
|
|
|---|---|
|
|
| generation | partial output is restored; request retried or failed within budget |
|
|
| tool with `replay: 'safe'` | runs again (once the tool API lands) |
|
|
| other tool | "interrupted" error result; the turn continues (once the tool API lands) |
|
|
| tool waiting on a child | waits again on the same child (once the mediated child API lands) |
|
|
| job | asks the process host for the run's status; if the host still knows it, continues; if not, reruns under the same key if `rerun` (possibly duplicating the effect), else `interrupted`. `nodeProcessHost()` forgets runs on restart |
|
|
|
|
```typescript
|
|
const { pending, running, orphaned, unknownEntryKinds } = await h.inspect(ctx);
|
|
```
|
|
|
|
`orphaned` are live tasks whose kind was not registered at open; they were terminalized. Register all
|
|
kinds before opening.
|
|
|
|
```typescript
|
|
await h.close(ctx); // stop, write nothing; everything resumes next open
|
|
await h.shutdown(ctx); // abort every live task cleanly, keep queued input, then close
|
|
```
|
|
|
|
Options:
|
|
|
|
| option | |
|
|
|---|---|
|
|
| `models` | pi-ai models; `generationKind.config.model` is resolved against it |
|
|
| `tools` | tool declarations the model may call; not usable yet, the execution API is gated (see Writing tools) |
|
|
| `taskKinds`, `entryKinds`, `sections` | plugin kinds and system section definitions |
|
|
| `root.config` | initial built-in config (`model`, `thinkingLevel`, `selectedTools`, ...) for a fresh session; ignored on reopen |
|
|
| `root.values`, `root.sections`, `rootValues` | advanced: arbitrary initial values / section payloads for the fresh root |
|
|
| `processHost` | how `pi.job` runs processes; `nodeProcessHost()` for local jobs (in-memory: forgets runs on restart); omitted means every job fails with reason `spawn` |
|
|
|
|
Registries supply implementations, not selections: registering `bash` does not select it. Select through
|
|
`c.config.selectedTools`. `h.tools`, `h.taskKinds`, `h.entryKinds`, `h.sections` have
|
|
`register`/`replace`/`remove` for changes after open; replacing or removing a task kind with live tasks
|
|
rejects. Hooks are registered on `h.hooks` or `c.hooks` (below), not in open options.
|
|
|
|
## Sending input
|
|
|
|
```typescript
|
|
const input = await c.send({ content: 'Inspect the parser', requestId: 'req-1' }, ctx);
|
|
input.id; // stable input id
|
|
await input.result(ctx); // queued | placed | done | unanswered, no waiting
|
|
const r = await input.wait(ctx); // done | unanswered
|
|
await input.abort(ctx); // aborted | already_placed | not_found
|
|
|
|
if (r.status === 'done') {
|
|
r.input; // UserEntry: the placed user entry
|
|
r.answer?.entry; // AssistantEntry, when an answer was owed
|
|
r.answer?.message; // the stored AssistantMessage (entry.model[0]); text, thinking, tool calls, usage
|
|
} else {
|
|
r.reason; // terminated | aborted | failed | stale
|
|
r.input; // present if the input had been placed before it went unanswered
|
|
}
|
|
```
|
|
|
|
Outcomes come with their entries attached; there is nothing to look up afterwards. `message` is the message as
|
|
stored, not the current context projection, so a later edit or compaction does not change what `wait`
|
|
returned.
|
|
|
|
`requestId` makes `send` idempotent for the session: retrying returns the original handle and writes nothing.
|
|
`h.input(requestId, ctx)` finds it later.
|
|
|
|
While the conversation is busy (a generation or tool is live), input is queued:
|
|
|
|
```typescript
|
|
await c.send({ content: 'then write tests' }, ctx); // followUp: after the answer
|
|
await c.send({ content: 'focus on the tokenizer', whenBusy: 'steer' }, ctx); // steer: at the next tool boundary
|
|
await c.send({ content: '...', whenBusy: 'reject' }, ctx); // throws ConversationBusy
|
|
```
|
|
|
|
Steer joins the current turn after the running tools finish. FollowUp starts a new turn after the final
|
|
answer. Both survive restarts. Passive content (a notice the model should see next time) uses `write`:
|
|
|
|
```typescript
|
|
await c.commit((tx) => tx.write(noticeKind, {
|
|
model: [{ role: 'user', content: 'CI finished: 3 failures', timestamp: Date.now() }],
|
|
}), ctx);
|
|
```
|
|
|
|
`write` appends immediately if idle, otherwise queues for the next boundary. It never asks for an answer.
|
|
|
|
There is no "queue this for the next run" mode. If you accumulate context that should not trigger a turn yet
|
|
(observations, partial instructions), keep it in your own value or list and fold it into a single `send` when
|
|
you do want an answer; use `write` only when a passive transcript entry is the point.
|
|
|
|
```typescript
|
|
await c.waitForIdle(ctx); // this conversation's foreground work
|
|
await h.waitForIdle(ctx); // whole session
|
|
await c.abort(ctx); // abort this conversation's foreground cancellation set (turn, foreground tasks, owned subagents); withdraws queued steer/followUp
|
|
await h.abortTask(id, ctx); // one task; queued input is kept
|
|
```
|
|
|
|
## Configuration
|
|
|
|
Built-in configuration is a set of typed properties on `c.config`, one per value, plus a batched setter:
|
|
|
|
```typescript
|
|
await c.config.model.set({ provider: 'openai', modelId: 'gpt-5.6' }, ctx);
|
|
await c.config.thinkingLevel.set('high', ctx);
|
|
await c.config.selectedTools.set(['read', 'grep'], ctx);
|
|
await c.config.followUpMode.set('all', ctx);
|
|
|
|
const model = await c.config.model.get(ctx); // current (or the definition's default)
|
|
const then = await c.config.model.get(entryId, ctx); // as of an entry (rewindable values only)
|
|
const mode = await c.config.followUpMode.get(ctx); // sticky: current only
|
|
|
|
// several at once, one commit; keys and value types are exact
|
|
await c.config.set({ model: { provider: 'openai', modelId: 'gpt-5.6' }, thinkingLevel: 'low', followUpMode: 'all' }, ctx);
|
|
const all = await c.config.get(ctx);
|
|
```
|
|
|
|
| property | type | default | rewindable |
|
|
|---|---|---|---|
|
|
| `model` | `ModelRef` (`{ provider, modelId }`) | none: generation fails `no_model` | yes |
|
|
| `thinkingLevel` | `ThinkingLevel` (`off` … `xhigh`, `max`) | `'off'` | yes |
|
|
| `selectedTools` | `string[]` | `[]` | yes |
|
|
| `profile` | `string` | `'default'` | yes |
|
|
| `retry` | pi-ai `RetryPolicy` | `{ enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60000 }` | no |
|
|
| `steeringMode`, `followUpMode` | `'all' \| 'one-at-a-time'` | `'one-at-a-time'` | no |
|
|
| `threshold` | `number` (context tokens; 0 = never) | `0` | yes |
|
|
| `keepRecent` | `number` (tokens kept after compaction) | `20000` | yes |
|
|
|
|
Defaults live on the definitions and are what `get`, `view.config` and `config` events return when nothing is
|
|
set; `delete` restores them. The types say so: `c.config.followUpMode.get(ctx)` is `'all' | 'one-at-a-time'`,
|
|
never `undefined`; only `model` (no default) can be `undefined`.
|
|
|
|
These are the same definitions the built-in tasks read (`generationKind.config.model` and friends). Each kind
|
|
exports a typed bundle of reusable conversation-value definitions; `c.config` is assembled from all of them,
|
|
so there is no second schema, and a name clash between kinds or with `set`/`get` is a compile error. The
|
|
definitions carry no conversation ID; the handle binds them, and you can pass them anywhere a conversation
|
|
value is accepted (`c.value(generationKind.config.model)`, `root.values`, child seeds). Plugin configuration is
|
|
not in `c.config`; plugins declare their own values and use `c.value(token)`.
|
|
|
|
Rewindable means a fork at yesterday's answer gets yesterday's model. Queue modes are sticky: they describe
|
|
how you want the present handled, not history. The next generation reads current values; nothing is cached.
|
|
|
|
## System prompt
|
|
|
|
Pico sends pi-ai `{ messages }` only. Instructions and the tool loadout are system messages inside the
|
|
transcript at the position they took effect, so the model's cached prefix survives a change and a fork sees
|
|
exactly what its source saw.
|
|
|
|
Sections are typed tokens with a renderer. A `systemInstructions` hook edits a draft before each request:
|
|
|
|
```typescript
|
|
import { defineSystemSection, systemSections } from '@earendil-works/pi-agent/pico';
|
|
import { generationKind } from '@earendil-works/pi-agent/pico/kinds';
|
|
|
|
const rules = defineSystemSection<string[]>({
|
|
key: 'myplugin.rules',
|
|
render: (r) => r.map((x) => `- ${x}`).join('\n'),
|
|
});
|
|
await h.sections.register(rules, ctx);
|
|
|
|
// host, harness-wide: sets the authoritative base every time, so it runs first
|
|
h.hooks.on(generationKind, generationKind.hooks.systemInstructions, ({ sections }) => {
|
|
sections.set(systemSections.identity, 'You are a coding assistant.');
|
|
sections.set(systemSections.skills, skillsCache.current);
|
|
sections.set(rules, ['Run relevant tests.']);
|
|
});
|
|
|
|
// plugin, on this conversation: runs after the host and modifies typed payloads, never parses prose
|
|
c.hook.systemInstructions(({ sections }) => {
|
|
sections.set(rules, [...(sections.get(rules) ?? []), 'Check migration safety.']);
|
|
sections.wrap(rules, (text) => `Repository policy:\n${text}`);
|
|
});
|
|
|
|
// rare: override the loadout for this request instead of c.config.selectedTools
|
|
c.hook.systemInstructions(({ tools }) => ({ tools: tools.filter((t) => t.name !== 'bash') }));
|
|
```
|
|
|
|
Handlers run harness-wide first, then outer conversation to inner, on one draft; the plugin above therefore
|
|
sees the host's base. Registration is synchronous and process-local (it returns an unsubscribe function);
|
|
`h.sections.register` is asynchronous because the section registry is shared state. `get` returns a copy;
|
|
`set` keeps an existing key's position; `delete` is explicit (null is a value); `wrap` transforms rendered
|
|
text and is not stored. A throwing handler loses only its own edits. The tool loadout is
|
|
`c.config.selectedTools` resolved against `h.tools`; a handler that returns `tools` overrides it for that
|
|
request.
|
|
|
|
After the hooks, the draft is compared to what was last stored. No change: nothing is written. A change:
|
|
one `pi.system` entry with the changed sections' payload and rendered text, and a system message with
|
|
`toolsRemoved`/`toolsAdded`. After a compaction or reset, the next request gets a fresh complete baseline.
|
|
A plugin that disappears leaves its rendered text in place until someone deletes the section.
|
|
|
|
Because handlers rerun on every preparation, a handler that appends to what it reads accumulates unless an
|
|
earlier handler resets the base. Make the chain idempotent as a set.
|
|
|
|
## Watching
|
|
|
|
```typescript
|
|
const w = await c.watch({ values: [planMode], lists: [moves] }, ctx); // or c.watch(ctx); === h.watchConversation(c.id, ...)
|
|
w.view; // ConversationView, captured atomically with the subscription
|
|
w.start((d) => { // one delivery per commit: { type: 'commit', first, last, events } | { type: 'closed', reason }
|
|
if (d.type === 'commit') for (const e of d.events) { /* w.view is already updated */ }
|
|
});
|
|
w.unsubscribe();
|
|
```
|
|
|
|
```typescript
|
|
// abbreviated sketch; the exact readonly declarations are in the handoff, section 14
|
|
interface ConversationView {
|
|
conversation: Conversation;
|
|
entries: Entry[]; // the active transcript in order: everything from the newest head's boundary through now
|
|
context: Id[]; // entry ids the model currently sees, derived from entries
|
|
tasks: Task[]; // live tasks in this conversation
|
|
taskOutputs: { id: Id; kind: string; value: OutputState }[]; // streaming assistant text, tool progress
|
|
inbox: Element<QueuedInput>[];
|
|
config: { model?, thinkingLevel, selectedTools, profile, retry, steeringMode, followUpMode, threshold, keepRecent }; // defaults resolved: every key except model is present and never undefined
|
|
values: { address: Value; value?: JsonValue }[]; // only what you asked for in { values: [...] }
|
|
lists: { address: List; elements: Element[] }[]; // only what you asked for in { lists: [...] }
|
|
readAt: Seq;
|
|
}
|
|
```
|
|
|
|
A requested list is captured whole and then kept current by `list` events (`append`/`remove`/`clear` ops in
|
|
commit order, already folded when the listener runs). Built-in config cannot be requested as a value; it is
|
|
always in `view.config`.
|
|
|
|
`view.config` has the same properties as `c.config`, so a status line reads `view.config.model` and a settings
|
|
panel binds to it directly; nothing needs to be requested. `values` is for your own state (`planMode` above).
|
|
|
|
Built-in config changes arrive as their own event, typed by key:
|
|
|
|
```typescript
|
|
w.start((d) => {
|
|
if (d.type !== 'commit') return;
|
|
for (const e of d.events) {
|
|
if (e.type === 'config') {
|
|
// e.key narrows e.value / e.previous
|
|
if (e.key === 'model') statusLine.setModel(e.value); // ModelRef | undefined
|
|
if (e.key === 'selectedTools') toolbar.set(e.value); // readonly string[]: defaulted, never undefined
|
|
}
|
|
if (e.type === 'value') plugins.update(e.value.address, e.value.value); // your requested values
|
|
if (e.type === 'list') board.render(w.view.lists); // ops already folded
|
|
}
|
|
});
|
|
```
|
|
|
|
`c.config.set({ model, followUpMode })` yields two `config` events in the same delivery; `w.view.config` already
|
|
reflects both when the listener runs. Read the initial state from `w.view.config`, then follow events.
|
|
|
|
The listener is a wake-up: `w.view` is already updated when it runs. A renderer that only looks at the view is
|
|
correct.
|
|
|
|
`entries` is what the conversation is currently working with, in transcript order, not a fixed number of
|
|
recent entries. It is captured once and then maintained incrementally: an ordinary entry appends; an entry that
|
|
carries a `head` (summary, reset, handoff) first drops the entries before its retained boundary and then
|
|
appends itself, all within the same delivery. A summary therefore sits after the entries it retains (the model
|
|
sees it first; that is context projection, not the view). A reset or handoff points at itself, so it clears the
|
|
list and becomes the first entry. The older transcript is still in storage and readable through `h.entries({ conversationId: c.id, limit: 50 }, ctx)` (newest first, paged) and `h.getEntry`, it just is
|
|
not part of the live view. There is no window size to tune.
|
|
|
|
| you want | look at |
|
|
|---|---|
|
|
| in a turn | `view.tasks` has a `pi.generation`/`pi.tool`/`pi.post_tools` |
|
|
| streaming text | `view.taskOutputs` entry for the generation's `output.id` |
|
|
| a tool running | `pi.tool` task; its `output` for progress |
|
|
| retrying | generation `checkpoint.phase === 'retrying'` |
|
|
| current model / tools / thinking | `view.config.model`, `.selectedTools`, `.thinkingLevel`; changes: `config` event with `key`/`value`/`previous` |
|
|
| compacting | `pi.collapse` task present |
|
|
| queued input | `view.inbox` |
|
|
|
|
Overflow (listener too slow, 256 commits buffered) closes the watch with `{ type: 'closed', reason:
|
|
'overflow' }`. Open a new one; its view is the fresh truth.
|
|
|
|
Remote UIs: ship `w.view` once, then every delivery. `applyConversationCommit(view, commit)` is exported and
|
|
needs no kinds.
|
|
|
|
```typescript
|
|
const sw = await h.watchSession({ values: [sessionName] }, ctx); // conversation list, session values
|
|
```
|
|
|
|
### Plugin state in a UI
|
|
|
|
Requested Pico values and lists (as in the watch above) are for the process that owns the Harness: the plugin's
|
|
session-side facet, or a host renderer in the same process. Pico does not push plugin state to remote UIs. A
|
|
plugin that has something to show elsewhere follows the Chord facet pattern
|
|
used by `coding-agent/examples/plugins/pi-example-plugin` and the experimental `models-provider` /
|
|
`transcript-provider` services:
|
|
|
|
```typescript
|
|
// contract.ts: what the UI may see and call
|
|
export interface PlanService {
|
|
readonly state: ReplicatedState<{ active: boolean; steps: string[] }>;
|
|
toggle(input: { active: boolean }, ctx: Context): Promise<void>;
|
|
}
|
|
export const PlanService = defineService<PlanService>('myplugin.plan');
|
|
|
|
// session.ts: runs beside the Harness; Pico is the durable truth, the replicated state is a projection
|
|
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
|
|
export default defineFacet({
|
|
id: 'myplugin/session',
|
|
setup(env) {
|
|
const state = env.replicatedState({ active: false, steps: [] });
|
|
env.provide(PlanService, {
|
|
state,
|
|
async toggle({ active }, ctx) {
|
|
await c.value(planMode).set(active, ctx); // durable
|
|
state.state.active = active; state.publish(ctx); // live
|
|
},
|
|
});
|
|
env.onActivate(async () => { // reopen: rebuild the projection from Pico
|
|
state.state.active = (await c.value(planMode).get(BACKGROUND_CONTEXT)) ?? false;
|
|
state.publish(BACKGROUND_CONTEXT);
|
|
});
|
|
},
|
|
});
|
|
|
|
// tui.ts: never sees the Harness or addresses
|
|
export default defineFacet({
|
|
id: 'myplugin/tui',
|
|
setup(env) {
|
|
const plan = env.use(PlanService);
|
|
env.onActivate(() => env.own(plan.state.subscribe((value) => badge.set(value.active)))); // (value, context, delivery) => void
|
|
},
|
|
});
|
|
```
|
|
|
|
Built-in config is the exception and is automatic (`view.config`); everything else a UI should see is a
|
|
service DTO the plugin defines.
|
|
|
|
## Forks, compaction, reset
|
|
|
|
```typescript
|
|
const alt = await c.fork({ at: entryId }, ctx); // shares history through entryId; independent after
|
|
const alt2 = await c.fork({ at: 'start' }, ctx); // empty transcript, same session
|
|
const back = await c.fork({ at: earlier, abort: true }, ctx); // "go back": abort the source's turn first
|
|
await alt.send({ content: 'try recursive descent instead' }, ctx);
|
|
```
|
|
|
|
Forks inherit rewindable state (model, tools) and the system messages visible at the fork point. They inherit
|
|
no tasks and no queued input. Nothing in the source changes.
|
|
|
|
```typescript
|
|
const taskId = await c.collapse({ instructions: 'keep API decisions verbatim' }, ctx);
|
|
await h.getTask(taskId, ctx);
|
|
```
|
|
|
|
Manual compaction runs in the background while the model keeps working; it declines with `NothingToCollapse`
|
|
if everything already fits in `keepRecent`. Threshold compaction is created by generation automatically when the
|
|
context estimate crosses `threshold` and runs in the foreground (aborting the conversation aborts it too). Both append a `pi.summary` entry with a head; the transcript keeps everything.
|
|
|
|
```typescript
|
|
await c.reset({ handoff: 'We settled on recursive descent; continue from the lexer.' }, ctx);
|
|
await c.reset(ctx); // /clear
|
|
```
|
|
|
|
Reset appends a `pi.handoff` or `pi.reset` entry with `head: self`. If the conversation is busy it is queued for
|
|
the next boundary; either way `reset` resolves once the request is durable, and the entry shows up in the watch
|
|
when it lands.
|
|
|
|
## Plugin state
|
|
|
|
```typescript
|
|
import { conversationValue, conversationList, sessionValue } from '@earendil-works/pi-agent/pico';
|
|
|
|
type Move = { readonly x: number; readonly y: number };
|
|
const planMode = conversationValue<boolean>('plan.mode', { rewind: true });
|
|
const moves = conversationList<Move>('game.moves', { rewind: true });
|
|
const expanded = conversationValue<boolean>('ui.expanded', { rewind: false });
|
|
const name = sessionValue<string>('app.name');
|
|
|
|
await c.value(planMode).set(true, ctx); // c binds it to its own conversation
|
|
await c.value(planMode).get(ctx);
|
|
await c.value(planMode).get(entryId, ctx); // rewindable only
|
|
const id = await c.list(moves).append({ x: 1, y: 2 }, ctx);
|
|
await c.list(moves).read(entryId, ctx);
|
|
await c.list(moves).remove(id, ctx);
|
|
await h.value(name).set('parser work', ctx);
|
|
|
|
planMode.bind(otherConversationId) // explicit Value<boolean> when you need one
|
|
```
|
|
|
|
A conversation address is declared once and bound when used. It carries rewind policy and payload type.
|
|
Your definitions work everywhere: `c.value`, commits, `root.values`, child seeds, watch `values`. Pico's
|
|
built-in config tokens (`generationKind.config.model` and friends) are exported and work the same way. Pico's
|
|
internals (inbox, input results, request receipts, task output) are not addressable through any
|
|
generic handle; they surface only as `view.inbox`, `InputHandle` and `view.taskOutputs`, and you cannot define
|
|
values in those namespaces.
|
|
|
|
Anything that must land together goes in one commit (assumes `noteKind` and `reminderKind` are registered, see
|
|
below; a kind object that is not the registered one is rejected as `StaleDefinition`):
|
|
|
|
```typescript
|
|
const id = await c.commit(async (tx) => {
|
|
const current = await tx.value(planMode).get(); // reads first
|
|
tx.value(planMode).set(!current); // rewindable state before entries
|
|
const note = await tx.write(noteKind, { data: { text: 'toggled' } });
|
|
tx.task(reminderKind, { background: true, input: { about: note, at: Date.now() + 3600_000 } });
|
|
return note;
|
|
}, ctx);
|
|
```
|
|
|
|
Rules inside a commit: reads before the first write (`ReadAfterWrite` otherwise); rewindable writes before
|
|
entry appends; no awaiting anything but `tx` reads; a throw discards everything.
|
|
|
|
## Writing tools
|
|
|
|
**The ordinary tool API is not settled.** This is the one deliberate gap in Pico v1: what a tool's `execute`
|
|
receives and how it streams progress is still being decided, so nothing in this section is a compile contract.
|
|
What *is* settled:
|
|
|
|
- A tool declares `name`, `description`, `parameters` (TypeBox schema), `replay: 'safe' | 'unsafe'` (may the
|
|
call be rerun after a crash) and `output` bounds (`maxBytes`, `maxLines`, `retain`).
|
|
- A tool returns a `ToolResult`: `content` (text/images the model reads), typed `details` for UIs, `isError`,
|
|
`usage`, `diagnostics` (commentary about the call, rendered after the output), and optional `control`.
|
|
- `control`: `{ terminate: true }` ends the turn after this exchange; `{ handoff: message }` ends it and resets
|
|
context with that message (what `new_context` does); `{ addTools: [...] }` adds tools to `selectedTools` so
|
|
the very next model request (normally the continuation right after this tool round) already offers them.
|
|
- Pico enforces the output bounds, reports truncation, stores the exact model message in the result entry and
|
|
the rest in its data, and streams the tool's live progress as the tool task's output (`view.taskOutputs`).
|
|
- A throw becomes an `isError` result; it never faults the session. Tools cannot touch the transcript, the task
|
|
or the inbox, and get no commit authority.
|
|
- Not yet available: durable per-call memos, starting a background job from a tool, starting a subagent from a
|
|
tool. Requirements are fixed (keyed and idempotent across a crash, cleanup policy recorded before the tool
|
|
returns, a wait timeout never cancels the child, a job is a task and a subagent is a conversation) but the
|
|
API shape is not. Until it is, the built-in `bash` cannot background and there is no `subagent` tool.
|
|
|
|
Illustration only (the `execute` signature and the `tool` facade are the gated part):
|
|
|
|
```typescript
|
|
// ILLUSTRATIVE, not the final API
|
|
const params = Type.Object({ path: Type.String(), pattern: Type.Optional(Type.String()) });
|
|
|
|
export const countLines = {
|
|
name: 'count_lines',
|
|
description: 'Count lines in a file',
|
|
parameters: params,
|
|
replay: 'safe',
|
|
output: { maxBytes: 64_000, maxLines: 200, retain: 'head' },
|
|
async execute(args /* Static<typeof params> */, tool /* gated facade */, ctx) {
|
|
const lines = (await fs.readFile(args.path, 'utf8')).split('\n');
|
|
const matching = args.pattern ? lines.filter((l) => l.includes(args.pattern!)) : lines;
|
|
return {
|
|
content: [{ type: 'text', text: matching.join('\n') }],
|
|
details: { lines: lines.length },
|
|
diagnostics: matching.length > 200 ? [{ severity: 'warn', message: `showing 200 of ${matching.length}` }] : [],
|
|
};
|
|
},
|
|
};
|
|
```
|
|
|
|
## Hooks
|
|
|
|
Hooks belong to the task kind that runs them; each kind declares its own typed points. The built-in points are
|
|
flattened onto `c.hook`, one method each: `systemInstructions`, `beforeRequest`, `afterResponse`, `onYield`,
|
|
`beforeTool`, `afterTool`, `afterTools`, `beforeCollapse`. Each is the generic
|
|
`c.hooks.on(kind, kind.hooks.point, handler, { subtree })` with kind and point bound; `h.hooks.on(...)`
|
|
registers harness-wide. Plugin kinds use the generic form with their own point tokens. Input, output and
|
|
failure behaviour come from the point:
|
|
|
|
```typescript
|
|
import { toolKind } from '@earendil-works/pi-agent/pico/kinds'; // only for the generic form
|
|
|
|
// conversation-scoped, named
|
|
c.hook.beforeTool(async ({ call }, { conversationId }, ctx) => { // a `block` from an earlier handler already stopped the chain
|
|
if (call.name === 'write' && await c.config.profile.get(ctx) === 'plan')
|
|
return { block: { reason: 'plan mode: no edits' } };
|
|
if (call.name === 'bash' && !(await ui.approve(call.arguments, ctx)))
|
|
return { block: { reason: 'denied by user' } };
|
|
return { call: { ...call, arguments: normalize(call.arguments) } }; // may rewrite arguments
|
|
}, { subtree: true }); // shared policy: applies to subagents this conversation owns too
|
|
|
|
c.hook.afterTool(async ({ call, result }) => { metrics.record(call.name); });
|
|
c.hook.beforeRequest(async ({ request }) => ({ request: withTracing(request) }));
|
|
c.hook.onYield(async ({ answer }, { conversationId }, ctx) => {
|
|
if (await goalIncomplete(conversationId, ctx)) return { continue: 'The goal is not met yet; continue.' };
|
|
});
|
|
c.hook.beforeCollapse(async ({ reason, entries }) => {
|
|
if (reason === 'manual' && entries.length < 10) return { decline: true };
|
|
});
|
|
|
|
// harness-wide, generic
|
|
h.hooks.on(toolKind, toolKind.hooks.beforeTool, handler); // same as c.hook.beforeTool, for every conversation
|
|
h.hooks.on(myKind, myKind.hooks.myPoint, handler); // your own kinds' points, by token
|
|
```
|
|
|
|
| kind | point | returns | several handlers | on throw |
|
|
|---|---|---|---|---|
|
|
| generation | `systemInstructions` | draft edits; `{ tools? }` | all run | its edits dropped, skipped |
|
|
| generation | `beforeRequest` | `{ request? }` | chained: each output is shallow-merged into the input the next handler sees | skipped |
|
|
| generation | `onYield` | `{ continue: string }` or nothing | first non-void answer wins | skipped |
|
|
| generation | `afterResponse` | nothing | all run | reported |
|
|
| tool | `beforeTool` | `{ call? }` or `{ block }` | chained (patch); `block` stops the chain | **blocks the tool** |
|
|
| tool | `afterTool` | `{ result? }` | chained (patch) | skipped |
|
|
| post_tools | `afterTools` | nothing | all run | reported |
|
|
| collapse | `beforeCollapse` | `{ decline }` or `{ instructions?, summary? }` | first answer wins | skipped |
|
|
|
|
Hooks run outside the commit line and may take as long as they like (a human approval is a hook that waits).
|
|
They may run again after a crash; key external side effects. Cancellation always propagates. `onYield`'s
|
|
`continue` appends a user entry and starts another generation for the same input; if the user typed something
|
|
meanwhile, that wins and the yield decision is dropped.
|
|
|
|
Your own task kinds declare points the same way (see below); `h.hooks.on(myKind, myKind.hooks.myPoint, ...)` then
|
|
type-checks against your declaration.
|
|
|
|
## Writing entry kinds
|
|
|
|
```typescript
|
|
import { defineEntry, type EntryBase, type EntryData, type ModelProjection } from '@earendil-works/pi-agent/pico';
|
|
|
|
type NoteEntry = EntryBase & EntryData<{ text: string }>;
|
|
export const noteKind = defineEntry<NoteEntry>('myplugin.note');
|
|
await h.entryKinds.register(noteKind, ctx); // or pass it in Harness.open({ entryKinds: [noteKind] })
|
|
|
|
// Data-only: the model sees nothing. `write` resolves with its admission/input id, not the later entry id;
|
|
// the committed entry appears in the watch.
|
|
await c.commit((tx) => tx.write(noteKind, { data: { text: 'plan accepted' } }), ctx);
|
|
|
|
type PinnedEntry = EntryBase & EntryData<{ text: string }> & ModelProjection<UserMessage>;
|
|
export const pinnedKind = defineEntry<PinnedEntry>('myplugin.pinned');
|
|
await h.entryKinds.register(pinnedKind, ctx);
|
|
const text = 'Always run the tests before claiming a fix works.';
|
|
await c.commit((tx) => tx.write(pinnedKind, {
|
|
data: { text },
|
|
model: [{ role: 'user', content: `<pinned>${text}</pinned>`, timestamp: Date.now() }],
|
|
}), ctx);
|
|
|
|
// later, from a watch event or an entry scan:
|
|
const entry = await h.getEntry(entryId, ctx);
|
|
if (noteKind.is(entry)) console.log(entry.data.text);
|
|
```
|
|
|
|
An entry may also carry `head` (context starts here) and `edits` (omit/replace earlier messages). A missing
|
|
plugin loses typed access, not context behaviour: facets are stored.
|
|
|
|
## Writing task kinds
|
|
|
|
```typescript
|
|
import { defineTask } from '@earendil-works/pi-agent/pico';
|
|
|
|
type Input = { readonly about: Id; readonly at: number };
|
|
type Checkpoint = { readonly phase: 'firing'; readonly key: string };
|
|
|
|
export const reminderKind = defineTask<Input, Checkpoint, { fired: boolean }, { message: string }, null>()({
|
|
kind: 'myplugin.reminder',
|
|
|
|
async execute(task, rt, ctx) {
|
|
if (task.input.at > rt.now()) await rt.sleep(task.input.at, ctx);
|
|
await rt.commit((tx) => tx.checkpoint({ phase: 'firing', key: `reminder:${task.id}` }), ctx);
|
|
await notifications.fire(task.input.about, `reminder:${task.id}`, ctx);
|
|
return () => ({ status: 'completed', result: { fired: true } });
|
|
},
|
|
|
|
async recover(task, rt, ctx) {
|
|
if (!task.checkpoint) return reminderKind.execute(task, rt, ctx); // crashed before the checkpoint: nothing external happened
|
|
// the checkpoint proves we intended to fire; the call is keyed, so firing again is safe if it did not land
|
|
if (!(await notifications.check(task.checkpoint.key, ctx))) {
|
|
await notifications.fire(task.input.about, task.checkpoint.key, ctx);
|
|
}
|
|
return () => ({ status: 'completed', result: { fired: true } });
|
|
},
|
|
|
|
async abort() {
|
|
return () => null;
|
|
},
|
|
});
|
|
|
|
await h.taskKinds.register(reminderKind, ctx); // fine for a fresh session. Once tasks of this kind can exist across restarts,
|
|
// pass it in Harness.open({ taskKinds: [reminderKind] }) on every open: open orphans
|
|
// live tasks whose kind is not registered *at open*, and nothing resurrects them
|
|
const id = await c.commit((tx) => tx.task(reminderKind, { background: true, input: { about: entryId, at: Date.now() + 3600_000 } }), ctx);
|
|
const task = await h.getTask(id, ctx);
|
|
```
|
|
|
|
The contract:
|
|
|
|
1. Pico marks the task `running` before calling `execute`. Commit a checkpoint before any external effect you
|
|
would need to reconcile after a crash.
|
|
2. `execute`/`recover` do their effects, then return a closure. Pico runs the closure on the commit line; its
|
|
writes, the outcome and scratch cleanup commit atomically. Do not perform effects in the closure.
|
|
3. `recover` is called instead of `execute` when a running task is found at open. Read `task.checkpoint` and
|
|
decide.
|
|
4. On abort, the old invocation is signalled through `ctx` and its later writes rejected. `abort` then runs
|
|
as a fresh call for cleanup and returns its own closure.
|
|
5. `after: [ids]` runs the task after those are terminal (any outcome). `rt.waitForTask(id, ctx)` observes.
|
|
6. Configuration lives in scoped values, not in task input. Task input is what recovery needs.
|
|
7. A task may `tx.write(...)` a passive entry (a notice) in any commit or in its terminal closure. It lands
|
|
now if the conversation is idle, at the next boundary if busy. Tasks never append directly and never ask
|
|
for an answer; that is `send`.
|
|
|
|
A kind may declare hook points. It decides when to run them; handlers registered on `h.hooks`/`c.hooks` for
|
|
this kind and point are invoked outside the line:
|
|
|
|
```typescript
|
|
import { defineHookPoint } from '@earendil-works/pi-agent/pico';
|
|
|
|
export const deployKind = defineTask<DeployInput, DeployCheckpoint, DeployResult, DeployFailure, null>()({
|
|
kind: 'myplugin.deploy',
|
|
hooks: {
|
|
beforeDeploy: defineHookPoint<{ target: string }, { approve: false; reason: string } | { approve: true }>({
|
|
fold: 'first', onThrow: 'abort',
|
|
}),
|
|
},
|
|
async execute(task, rt, ctx) {
|
|
const decision = await rt.hooks.run(deployKind.hooks.beforeDeploy, { target: task.input.target }, ctx);
|
|
if (decision.threw || decision.output?.approve === false)
|
|
return () => ({ status: 'failed', failure: { message: 'deployment declined' } });
|
|
...
|
|
},
|
|
...
|
|
});
|
|
|
|
await h.taskKinds.register(deployKind, ctx);
|
|
h.hooks.on(deployKind, deployKind.hooks.beforeDeploy, async ({ target }, info, ctx) =>
|
|
(await ui.approve(`deploy to ${target}?`, ctx)) ? { approve: true } : { approve: false, reason: 'denied' });
|
|
```
|
|
|
|
`fold` says how several handlers combine (`collect` all outputs, `first` non-void wins, `chain` shallow-merges
|
|
each handler's patch into the input the next handler sees). `onThrow` says whether a throwing handler is skipped
|
|
or stops the point.
|
|
|
|
Kinds declaring `output` get `rt.output` (read/mutate/replace) whose deltas stream to watchers:
|
|
|
|
```typescript
|
|
// abbreviated: recover/abort omitted
|
|
type Progress = { pct: number; log: string[] };
|
|
const progressKind = defineTaskOutput<Progress>('myplugin.progress');
|
|
defineTask<{ readonly steps: number }, never, { readonly done: true }, { readonly message: string }, null, Progress>()({
|
|
kind: 'myplugin.long',
|
|
output: { kind: progressKind, initial: () => ({ pct: 0, log: [] }) },
|
|
async execute(task, rt, ctx) {
|
|
await rt.output.mutate((o) => { o.pct = 50; o.log.push('halfway'); }, ctx);
|
|
return () => ({ status: 'completed', result: { done: true } });
|
|
},
|
|
// ...
|
|
});
|
|
```
|
|
|
|
## Subagents and jobs
|
|
|
|
A subagent is a conversation owned by the task that created it. There is no subagent task and no subagent
|
|
object; the model will get one tool, the API gets a conversation handle. The tool-side operation is not yet
|
|
specified (see Writing tools). What is settled: a child never inherits the parent's `c.config` implicitly. Its
|
|
creator seeds `model`, `selectedTools`, `profile` and any other definitions it wants, plus initial system
|
|
section values, in the same commit that creates the child and accepts its first input, so the first
|
|
generation already sees them. A child created without a first input is inert; register its hooks and set its
|
|
config, then `send`. From the host a subagent is just another conversation: `h.conversation(id,
|
|
ctx)`, `h.conversations({ owner, limit: 100 }, ctx)`. Its own `systemInstructions` hooks (registered on the child
|
|
handle or inherited via `subtree`) give it its own identity. Aborting the owning tool call aborts it unless
|
|
the (gated) child API created it with `abortWithTool: false`.
|
|
|
|
A job is a process:
|
|
|
|
```typescript
|
|
// Harness.open(storage, { ..., processHost: nodeProcessHost() }, ctx) -- without a host, jobs fail with reason 'spawn'
|
|
const id = await c.commit((tx) => tx.task(jobKind, {
|
|
background: true,
|
|
input: { command: 'npm', args: ['run', 'dev'], cwd, notify: false, rerun: true },
|
|
}), ctx);
|
|
await h.abortTask(id, ctx); // SIGTERM, then SIGKILL
|
|
```
|
|
|
|
`nodeProcessHost()` keeps runs in memory only: after a restart it no longer knows them, so a job either
|
|
reruns (`rerun: true`, same key, possibly repeating the effect) or fails `interrupted`. A supervising host can
|
|
do better; that is the host's contract, not Pico's.
|
|
|
|
Its stdout/stderr tail (and how many bytes were dropped) is in its task output; `notify: true` appends a `pi.notice` when it finishes (per occurrence for a schedule) so the
|
|
model sees it on its next request (which may be the continuation of the current turn).
|
|
|
|
## Storage backends
|
|
|
|
```typescript
|
|
MemoryStorage.create() // tests
|
|
await JsonlStorage.open('./session.jsonl', ctx) // local; one file plus live sidecars
|
|
await SqliteStorage.open('./sessions.db', { session: 'parser' }, ctx) // many sessions, servers
|
|
```
|
|
|
|
Same behaviour everywhere. JSONL and SQLite take a cross-process lock; a second writer fails rather than
|
|
racing.
|