diff options
Diffstat (limited to 'docs/archive/pluggable-session-store.md')
| -rw-r--r-- | docs/archive/pluggable-session-store.md | 236 |
1 files changed, 236 insertions, 0 deletions
diff --git a/docs/archive/pluggable-session-store.md b/docs/archive/pluggable-session-store.md new file mode 100644 index 0000000..9b45864 --- /dev/null +++ b/docs/archive/pluggable-session-store.md @@ -0,0 +1,236 @@ +# Plan: Agent-owned, pluggable session persistence + +## Problem + +Session logging is implemented as a single concrete type, `SessionManager` +(`libpanto/src/session_manager.zig`), that hard-codes filesystem JSONL I/O, +the `<uuidv7>.jsonl` filename scheme, and the directory layout. There is no +interface seam: a different `libpanto` consumer (e.g. a web service backed by +Postgres) cannot swap in its own persistence. + +Worse, persistence is currently driven *entirely outside* `libpanto`. The +`panto` CLI translates in-memory `Conversation` messages into on-disk +`DiskMessage`s (`src/session_persist.zig`), supplies metadata +(provider/model/`stop_reason`/`Usage`), and sequences the append calls around +`agent.runStep`. The `Agent` never touches the session log. + +## Goal + +`libpanto` consumers get persistence **for free**. The `Agent` owns the live +`Conversation` and a `SessionStore`, and persists everything it generates as +turns progress. `libpanto` ships two backends: `FSJSONLStore` (today's +behavior, constructed with a directory) and `NullStore` (no-op, for embedders +who opt out). The `panto` CLI resolves the directory, constructs an +`FSJSONLStore`, and hands it to the `Agent`. + +## Design decisions (settled) + +1. **`Agent` centralizes.** The `Agent` owns its `Conversation` (as + `agent.conversation`) and its `SessionStore`. Turn-driving methods stop + taking a `*Conversation` parameter and operate on the owned conversation. + Fewer, more powerful types on the API surface. `Conversation` and + `SessionStore` are sub-components of the `Agent`'s responsibility. + +2. **Persistence binds to conversation mutation, not to `runStep`.** Every + message that enters the conversation persists when added: + - `agent.submitUserMessage(text)` — adds + persists a user entry, stamped + with `config.provider`/`model`. The user prompt is durably logged + *immediately on submission*, before any provider call. + - `agent.addSystemMessage(text, mode)` — adds + persists a system entry. + - `agent.runStep(receiver)` — persists assistant + tool-result messages it + generates internally. + - `agent.compact(...)` — persists its own result. + + The embedder never calls a persist function. + +3. **The store has a read side, on the interface.** A `SessionStore` must be + able to reconstruct a `Conversation` (a single linear chain). Required on + the interface — a store you can't read is not a valid store. Later `/tree` + functionality (out of scope here) will use the read side to enumerate many + `Conversation`s (one per leaf / per unique prefix); the current + `ArrayList(Message)` shape supports that naturally. + +4. **`Conversation` is always a single linear chain.** Session branching + (future) lives in the store's tree of entries, not in `Conversation`. The + read side flattens a chosen path into one `Conversation`. + +5. **Resume returns `{ Conversation, ?dangling_user }`.** The read side + returns the reconstructed conversation plus an optional dangling trailing + user-prompt string (a user entry with no following assistant — e.g. a + crash/quit right after submission). The reconstructed `Conversation` + **excludes** that dangling user turn, so a resumed agent never auto-sends + it. Today's `panto` throws the optional string away. A future TUI will + prefill it for editing — which becomes the first real **session branch** + (an edited resubmission is a tree sibling sharing the original's + `parent_id`). Do not build branching now; just shape the return type so the + dangling prompt is discoverable. + +6. **`Agent.init` takes an optional `Conversation` to adopt.** `null` → fresh + empty conversation. Non-null → adopt (take ownership) as the live + conversation. Resume is an embedder choice expressed through types: open the + store, ask it for the conversation, hand it to `Agent.init`. No hidden + "replay mode" inside the agent. + +7. **Naming:** `FSJSONLStore`, `SessionStore`, `NullStore` (CamelCase acronyms + fully capitalized per `AGENTS.md`). + +## Verified facts (from code, not assumed) + +- **`Agent` does not touch persistence today.** It drives the provider/tool + loop and mutates a `Conversation`. So there is nothing to "unhook" — only new + ownership to add. +- **`Message.usage` is canonical.** Both providers write usage onto the + conversation message via `Conversation.addAssistantMessageWithUsage` + (`provider_anthropic_messages.zig:426`, `provider_openai_chat.zig:541`) with + the same `?Usage` they pass to `onMessageComplete`. The agent can capture + per-message usage directly from `conv.messages[i].usage` — **no receiver + dependency.** The CLIReceiver's `per_message_usage` list was a redundant + second copy and can be dropped from the persistence path. +- **Construction ordering.** System-prompt seeding needs + `luarocks_rt.layout.agent_dir` (only available after luarocks bootstrap) and + needs the store. The `Agent` holds a `*const Config` whose `registry` pointer + can be populated *after* the agent is constructed. So the new order works: + build store → build empty registry → build agent (adopts conversation + + store) → luarocks bootstrap → seed/reconcile system prompt **through the + agent** → load extensions into the registry. No deep dependency conflict; + it's a reshuffle. + +## Persistence call sites today (all in `panto`) + +- `src/main.zig`: `openSession` (init/open/resume), `appendUserPromptToSession`, + `persistTurn`/`persistCompaction` after `runStep`, plus + `getEntries`/`getSessionId`/`rebuildConversation`. +- `src/system_prompt.zig`: `seedFresh`/`reconcileResume` append system entries + via `mgr.appendMessage`. +- `src/compaction.zig` (`/compact`): `persistCompaction`. +- `src/command.zig`: `Context` holds `*SessionManager`; Lua commands reach it. + +All of these collapse into agent methods or move into `libpanto`. + +--- + +## Phase 1 — Define the interface (`libpanto/src/session_store.zig`, new) + +`pub const SessionStore = struct { ptr: *anyopaque, vtable: *const VTable }`, +matching the existing `Tool`/`ToolSource`/`Provider`/`Receiver` seams. + +VTable methods (each takes `ctx`): + +- `appendMessages(ctx, alloc, []DiskMessage, providers, models) !void` — the + batch-atomic primitive (mirrors today's `appendMessagesAtomic`; single append + is len-1). +- `loadConversation(ctx, alloc) !LoadedSession` where + `LoadedSession = struct { conversation: Conversation, dangling_user: ?[]const u8 }`. + Required read side (decision 3 + 5). +- `sessionId(ctx) []const u8`. +- `activeModel(ctx) ?struct { provider: []const u8, model: []const u8 }`. + +Notes: +- Re-export the disk types (`DiskMessage`, `DiskContentBlock`, `Usage`, …) here + so the interface is self-contained. The wire types in `session.zig` remain + the *default format* but the interface traffics in `DiskMessage` as the + neutral in-memory representation (a Postgres backend maps `DiskMessage` to + columns; it need not emit JSONL). +- **Drop `getSessionFile()` from the neutral interface** (filesystem-specific). + Keep it only on the concrete `FSJSONLStore` for CLI display/resume. +- Catalog operations (`listSessions`, `findMostRecentSession`, + `resolveSessionId`) are **not** on the interface — they are backend-specific + resume/listing helpers (a web backend lists via SQL). They stay as free + functions on the `FSJSONLStore` module. + +## Phase 2 — Backends + +- **`FSJSONLStore`** (rename within `session_manager.zig`, or new + `libpanto/src/fs_jsonl_store.zig` wrapping it): keep concrete constructors + `init(alloc, io, dir, cwd)` and `open(alloc, io, file_path)`; add + `store(self) SessionStore` returning the vtable wrapper (like + `rt.toolSource()`). Implement the vtable by delegating to existing methods + (`appendMessagesAtomic`, `activeModel`, …). Implement `loadConversation` from + the existing `rebuildConversation`, adding dangling-user detection (trailing + user entry with no following assistant → exclude from the rebuilt + conversation, return its text as `dangling_user`). +- **`NullStore`** (`libpanto/src/null_store.zig`, new): all appends no-op, + `loadConversation` returns an empty conversation + `null`, `activeModel` + null, `sessionId` a fixed/empty id. + +## Phase 3 — Agent owns the conversation, store, and persistence + +- Add fields: `conversation: Conversation` and `session_store: SessionStore` + (default `NullStore` singleton so existing `Agent.init` callers/tests keep + compiling with no persistence). +- `Agent.init(alloc, io, config, store, maybe_conversation)`: + - `maybe_conversation == null` → fresh empty `Conversation`. + - non-null → adopt it (take ownership). +- New / changed methods (drop the `*conv` parameter; operate on + `self.conversation`): + - `submitUserMessage(text)` — `conversation.addUserMessage` + persist a user + entry stamped from `self.config.provider`/`model`. + - `addSystemMessage(text, mode)` — add + persist a system entry. + - `runStep(receiver)` — snapshot `conversation.messages.len` at entry; on exit + (including error paths that committed messages), persist `[start..]` as + assistant/tool-result entries. **Move** `persistTurn`, + `hasToolUseWithoutFollowingResults`, and the disk-mapping out of + `src/session_persist.zig` into `libpanto` (agent or a new + `libpanto/src/turn_persist.zig`). + - `compact(prompt, extra)` — persist its own result (absorb + `persistCompaction`). +- **Usage capture:** read from `self.conversation.messages[i].usage` (verified + canonical). No receiver involvement. +- **`stop_reason`:** keep `"stop"` initially (matches today). Real wire value is + a follow-up (needs Receiver/provider plumbing). +- **Partial turns on error:** the agent now owns persisting messages it + committed before a `runStep` error (today the CLI persists after a failed + `runStep`). Preserve that behavior. + +## Phase 4 — `panto` CLI shrinks + +- **Delete `src/session_persist.zig` entirely** (logic moved into `libpanto`). +- `openSession`: construct an `FSJSONLStore` (concrete), keep the handle for + resume/listing, call `store.loadConversation` on resume to get + `{ conversation, dangling_user }`. Pass `store.store()` and the conversation + into `Agent.init`. Ignore `dangling_user` for now (do-nothing per decision 5). +- **Reorder construction** (decision: verified safe): store → empty registry → + agent (adopts conversation + store) → luarocks bootstrap → seed/reconcile + system prompt **through the agent** → load extensions into the registry. +- `src/system_prompt.zig`: `seedFresh`/`reconcileResume` call + `agent.addSystemMessage` instead of `mgr.appendMessage`. (System-prompt + sourcing stays CLI policy; only the persist mechanism changes.) +- REPL loop: `agent.submitUserMessage(line)` then `agent.runStep(&recv)`. + Remove `entries_before_step`, the `persistTurn`/`persistCompaction` calls, and + the `per_message_usage` → persistence plumbing. (CLIReceiver keeps usage only + if it still *displays* it.) +- `/compact` command: just `agent.compact(...)`. +- `src/command.zig` `Context`: drop the separate `*conv`; commands reach + `ctx.agent.conversation`. Keep a concrete `*FSJSONLStore` (or the interface) + for Lua commands that use `getEntries`/`getSessionId` — confirm exact usage. + +## Phase 5 — Tests & docs + +- Add an in-memory capturing `SessionStore` test double in `libpanto`; move the + `persistTurn` round-trip coverage there (verifies the agent persists the right + `DiskMessage`s without touching disk). +- Keep `FSJSONLStore` file-format tests where they are (concrete backend). +- Add a `NullStore` test (agent runs a turn; nothing persisted; no error). +- Add a `loadConversation` dangling-user test (trailing user entry excluded + from the conversation, returned as `dangling_user`). +- Update `root.zig` exports: `session_store`, the renamed `FSJSONLStore` + module, `null_store`. +- Update `docs/overview.md` persistence description and any libpanto docs. + +## Open / deferred (explicitly out of scope now) + +- **`/tree` and full session branching.** The read side is shaped to support it + (returns conversation-shaped data; dangling prompt discoverable) but + enumeration of all leaves/prefixes and leaf selection is future work. +- **Real `stop_reason`** plumbing through the Receiver. +- **TUI resume prefill** of the dangling user prompt (and the sibling-branch + write it implies). No-op for now. + +## Suggested landing order + +1. Phase 1 + 2 (interface + `FSJSONLStore`/`NullStore`), with the CLI still + calling persistence *through the interface* — de-risks the metadata/usage + move while keeping behavior identical. +2. Phase 3 — move persistence into the agent; make `Agent` own the conversation. +3. Phase 4 — collapse the CLI call sites and reorder construction. +4. Phase 5 — tests + docs throughout (not deferred to the end in practice). |
