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).
|