summaryrefslogtreecommitdiff
path: root/docs/archive/pluggable-session-store.md
blob: 9b458648f6f1a2632dd3942f4acce32b2c7b7b12 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
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).