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
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
|
# Plan: `libpanto` language bindings (Go + Python)
## Goal
Expose `libpanto` to other languages, targeting **Go** and **Python** first.
The bindings are organized as a family of sibling packages, named uniformly:
| package | language | role |
| ------------- | -------- | ------------------------------------------------------------- |
| `libpanto` | Zig | the core library (exists today) |
| `libpanto-c` | Zig | a C-ABI shared library + header wrapping the public Zig API |
| `libpanto-go` | Go | idiomatic Go bindings over `libpanto-c` via cgo |
| `libpanto-py` | Zig | a CPython extension implemented in pure Zig (`@cImport`) |
Two consumers, two paths to the core:
- **Go → `libpanto-c` → `libpanto`.** cgo is effectively the only option, and
cgo can only call C. So a C ABI is mandatory.
- **Python → `libpanto`** directly. `libpanto-py` is a native CPython
extension written *in Zig*: it `@cImport`s `Python.h`, builds `PyObject`
glue against the translated C types, and `build.zig` emits the loadable
`.so`. It calls the Zig API directly and **does not** depend on `libpanto-c`.
### Why this split (decisions settled)
- **`libpanto-c` is built regardless**, because Go requires it. It doubles as
the stable-ABI validation platform for the C surface.
- **No cffi.** A pure-Python cffi binding was considered and rejected. Its only
real appeal (no native build step, trivial wheels) holds solidly *only* in
ABI mode, which is also where streaming callbacks and silent struct/ABI
drift bite hardest; API mode reintroduces the C-compile/per-platform-wheel
cost without the control of a real native module. Either way it would be a
throwaway second validator of the same C surface that `libpanto-go` already
exercises. Net: redundant. We'd reimplement it natively on the soon side.
- **`libpanto-py` is pure Zig, not C-over-`libpanto-c`.** The decisive win is
that a Zig→`.so` compilation involves **no C translation units in the repo at
all** — one toolchain, header translation done at compile time by `@cImport`.
Going straight to the Zig API (rather than through `libpanto-c`) also skips a
redundant marshalling layer.
## The core architectural decision: ship a *pull* streaming API
This is the spine of the whole project, and it requires an **internal refactor
of `libpanto` first** (Phase 0). Everything else wraps it.
### Why pull, not push
`libpanto` today streams via a **push** `Receiver` vtable
(`provider.zig`): the provider loop calls `onMessageStart`, `onContentDelta`,
`onBlockComplete`, `onMessageComplete`, etc. Push is the wrong primitive to
*export*, for two independent reasons that land on the same conclusion:
1. **It doesn't compose into the idiomatic surfaces we want.**
- Go: the idiomatic streaming shape is a single goroutine driving a
range-over-func iterator (`for ev := range stream.Iter`) and/or a channel.
cgo callbacks *into* Go are slow and awkward (`//export`, pointer rules,
no closures). A goroutine draining a pull API is the clean build.
- Python: a (sync or async) generator **is** a pull construct —
`__next__` *is* "give me the next event." A push callback can't `yield`;
to turn pushed values into a `for ev in stream`, you must run the producer
and consumer in different stack frames concurrently (a thread + queue).
So push → generator *forces* a thread+queue regardless. Pull → generator
is a 1:1 mapping with zero adaptation.
2. **Pull is the more primitive primitive.** Push composes trivially on top of
pull (`while (try s.next()) |ev| receiver.onEvent(ev);` — five lines).
Pull does **not** compose cheaply on top of push: to expose `next()` over a
`Receiver`-driven core you must suspend *inside* a callback and resume the
provider loop later, which means a thread+queue or hand-rolled coroutine
state — i.e. you rebuild pull anyway, the hard way.
**Conclusion:** ship only the pull API. `Receiver` leaves the public surface
entirely. Anyone who wants push writes the five-line wrapper themselves.
### The Zig interface
```zig
// run() drops the receiver parameter entirely and returns a Stream.
// Whether it also drops `conv` depends on sequencing against
// docs/pluggable-session-store.md (see note below) — either form is fine.
pub fn run(self: *Agent) !Stream // or: run(self: *Agent, conv: *Conversation) !Stream
// Stream is a resumable handle, closely linked to SSEParser + the provider
// read loop. next() yields events until the turn is exhausted.
pub fn next(self: *Stream) !?Event
```
`Stream.next()` returns `!?Event`, which gives **three orthogonal channels**:
| signal | meaning |
| ------------------ | ---------------------------------------------------- |
| `Event` (a value) | streaming progress, including the terminal event |
| `null` | the stream is exhausted (already past the terminal) |
| `error.X` | a genuine failure (network, parse, provider error) |
### The terminal-event invariant (documented contract)
> Every `next()` call on a stream **after** it has yielded a `MessageComplete`
> event returns `null`, and `null` is **never** returned before
> `MessageComplete`.
Consequences, and why this exact shape:
- **`MessageComplete` is a normal `Event` variant**, not an error and not
signalled by `null`. It is the in-band success terminal.
- **Intelligent consumers never observe `null`.** They stop after *consuming*
`MessageComplete`. `null` exists only as the defensive answer to "you called
`next()` one time too many," distinct from a real `error`.
- Python: emit `MessageComplete`, then raise `StopIteration` on the *next*
call (which sees `null`) — or, more precisely, the generator stops right
after yielding `MessageComplete`, so well-behaved code raises `StopIteration`
without ever materializing a `None`.
- Go: the `Iter` range-over-func stops *after* yielding `MessageComplete`, not
by waiting to observe a `null` sentinel.
- Mid-stream **provider errors surface as a Zig `error` from `next()`** (the
`!` in `!?Event`), not as an `Event.Error` variant. This keeps the `Event`
union success-only and maps cleanly to a raised Python exception and a Go
`error`. (`error.StreamExhausted` is *not* used — exhaustion is `null`, not
an error; we reserve errors for failures.)
### `Event` spans the whole turn, not one HTTP response
Verified from the code: `Agent.runStep` wraps **multiple** `streamStep` calls
in a tool-using turn (one provider stream per assistant message), with
concurrent tool dispatch *between* them (`agent.zig`). The `Stream` therefore
spans the entire `run()` loop — provider streaming **and** tool dispatch — not
a single SSE response. The `Event` union must express both layers.
The exact variant list is to be finalized against what the provider loop and
agent loop emit today (see `ReceiverVTable` in `provider.zig` and the dispatch
path in `agent.zig`), but it covers at least:
- message lifecycle: `MessageStart`, `MessageComplete`
- block lifecycle: `BlockStart`, `BlockComplete`
- content: `ContentDelta`
- tool identity: `ToolDetails` (id + name)
- tool dispatch boundaries: tool-call start / tool-result (so Go/Python
consumers can observe the agent running tools between provider turns)
- provider retry notices: `ProviderRetry` (informational; today `onProviderRetry`)
`Event` is an `enum union`; it is the single type every binding marshals into
its native form. Define it once, in `libpanto`.
## The hard part of Phase 0: making `Stream.next()` resumable
Verified from `provider_openai_chat.zig`: the current provider loop is a
single-stack-frame loop —
```
readVec(body_reader) -> parser.feed(chunk) -> handleEvent(...) -> receiver.*(...)
```
— with all state (the HTTP body reader + `transfer_buf`, the `SSEParser`, the
per-response `StreamState`) living **on the stack**. A pull `next()` must
*return* between events, so that state has to move into the `Stream` handle.
Three ways to get there:
1. **State machine (recommended target).** The `Stream` owns the socket/body
reader, the `SSEParser`, and the decode state; `next()` reads/parses just
enough to produce one `Event` and returns. No threads. Cleanest and most
portable across all four packages. Most upfront refactoring effort, since
the provider loop is inverted into a resumable step function.
2. **Thread + queue inside the handle.** Keep the existing
`streamStep(receiver)` loop verbatim; run it on a thread whose receiver
pushes events into a bounded queue; `next()` pops. Minimal change to proven
code, but costs a thread per active stream plus shutdown/backpressure care.
A reasonable interim if (1) proves invasive.
3. **Zig async/suspend.** Language-level coroutines. Availability depends on the
toolchain's async status at the time of implementation; treat as
not-reliably-available without checking first.
The internal mechanism (1 vs 2) is swappable later **without changing the
exported contract** — the public surface is pull either way. Recommendation:
target (1); fall back to (2) only if the provider-loop inversion is too costly
for v1.
> Expectation: **Phase 0 is a net-red diff.** Deleting `Receiver` from the
> public API, the `CompactionCapture` no-op receiver, and the CLI's
> `CLIReceiver` vtable plumbing should remove more than the `Stream` machinery
> adds.
---
## Phase 0 — internal refactor of `libpanto` to a pull API
The real work. Everything after this is wrapping.
1. **Define the `Event` union** (success-only `enum union`) from the current
`ReceiverVTable` callbacks plus the agent's tool-dispatch boundaries. This
is the type every binding marshals.
2. **Define the `Stream` type**: a resumable handle owning the provider read
loop's state (body reader, `SSEParser`, decode state) and the agent's
tool-dispatch position. `fn next(self: *Stream) !?Event`. This is where the
state-machine-vs-thread decision lands.
3. **`Agent.run() -> Stream`.** With `conversation` owned by the `Agent` (per
`docs/pluggable-session-store.md`) and `receiver` removed, `run()` takes
only `self`. The agent loop (provider stream → tool dispatch → repeat)
becomes the thing `Stream.next()` drives incrementally.
4. **Delete `Receiver` from the public API** (`root.zig`, `provider.zig`).
Internal seams that genuinely want push (if any) get the trivial pull→push
wrapper, in caller code, not in the library surface.
5. **Rebuild the CLI on the pull loop.** `src/main.zig`'s `CLIReceiver`
collapses into a `while (try stream.next()) |ev| render(ev)` loop. Prove the
refactor end-to-end against the existing test suite; CLI behavior unchanged.
**Exit criteria:** `Receiver` gone from `root.zig`; `Stream.next()` is the only
streaming primitive; CLI output and the full test suite are unchanged.
> Sequencing with `docs/pluggable-session-store.md` is **flexible**, not a
> dependency. That plan moves `Conversation` onto the `Agent`; this one removes
> `receiver`. The two are orthogonal:
> - If the session-store work lands first, `run()` takes only `self`.
> - If this work lands first, `run(self, conv)` takes a single `conv` argument
> (just `runStep` minus `receiver`), and the session-store refactor drops
> `conv` later.
>
> Either way `receiver` is gone after Phase 0. Don't block on ordering.
## Phase 1 — `libpanto-c` (C ABI)
6. **Opaque handles** for the major types: `PantoAgent`, `PantoStream` (and
whatever construction requires — config, conversation/session store).
Consumers never see the layout.
7. **`panto_next_event(stream, *PantoEvent) -> status`** is the C projection of
`!?Event`. The `!?Event` triple maps onto a status enum plus an out-param:
| Zig `next()` | C status | out-param |
| ----------------------- | ------------ | ---------------- |
| `Event` value | `EVENT` (0) | filled |
| `null` | `DONE` (1) | untouched |
| `error.X` | `ERROR` (2) | error detail |
8. **`@export` wrappers** for construction, `run()`, `next_event`, teardown,
and a free function for any owned event payloads.
9. **The C header is committed and hand-maintained, not generated by
`build.zig`.** (Open question 1 below: we are *not* having `build.zig` emit
`panto.h`.) A stable, hand-written `include/panto.h` is the ABI contract —
it should change deliberately, be reviewable in diffs, and not be a build
artifact. `build.zig` emits the shared library and stages the committed
header; it does not author it.
10. **Define the event-marshalling C structs once.** `PantoEvent` (a tagged
union mirroring the Zig `Event`) is read by every downstream binding.
## Phase 2 — `libpanto-go` (validates `libpanto-c`)
11. **cgo bindings**: `Agent`, `Stream`, and the raw
`Stream.Next() (Event, bool, error)` (or `(Event, error)` with a separate
done signal) mapping `EVENT`/`DONE`/`ERROR` onto Go's idioms.
12. **`Stream.Iter() iter.Seq[Event]`** — the modern range-over-func form, so
consumers write `for ev := range stream.Iter { ... }`. Single goroutine,
auto-terminates after yielding `MessageComplete`, surfaces a failure via a
trailing `stream.Err()` after the range (the idiomatic Go pattern since
`bufio.Scanner`). This is the primary Go surface.
13. **A channel wrapper too.** Go users expect channels; a goroutine drains
`Next()` into a channel. Cheap over a pull core. Ship both the raw
iterator and the channel form.
`libpanto-go` is the validation harness for the `libpanto-c` ABI — if Go can
drive a full streaming turn idiomatically, the C surface is sound.
## Phase 3 — `libpanto-py` (pure Zig CPython extension)
14. **`build.zig` does `@cImport(@cInclude("Python.h"))`** and emits
`_panto.so` (a loadable extension module). No C in this package. Decide
**stable ABI (`abi3` / limited API)** here to collapse the
CPython-version × platform wheel matrix to one artifact per platform.
15. **`module.zig`** implements `PyModuleDef`, `PyMethodDef`, and an `Agent`
type and `Stream` type as `PyTypeObject`s, calling the **Zig** `libpanto`
API directly. The `Stream` type's `tp_iternext` calls Zig `Stream.next()`:
- `Event` → build and return the `PyObject` event.
- `null` → set `StopIteration` (return `NULL` with no error set).
- `error.X` → set the mapped Python exception.
- Wrap the blocking `next()` in `Py_BEGIN_ALLOW_THREADS` /
`Py_END_ALLOW_THREADS` so streaming doesn't serialize the whole
interpreter; re-acquire the GIL to build the event `PyObject`.
16. **`panto/__init__.py`** is a thin Pythonic surface over the native
`_panto`: a real exception hierarchy, context managers, idiomatic event
objects. **The async generator is pure Python** — wrap the sync iterator
with `asyncio.to_thread` (blocking `next()` runs in a worker thread). No
file descriptors, no ABI change. (See "Out of scope for v1" below.)
## The one contract that unifies all four packages
| layer | progress / terminal | exhausted | failure |
| -------- | ------------------------- | ------------------ | ---------------------- |
| Zig | `Event` / `MessageComplete` | `null` | `error.X` |
| C | `PantoEvent` / status `EVENT` | status `DONE` | status `ERROR` |
| Go | `Event` (Iter yields) | Iter stops | `error` / `stream.Err()` |
| Python | event object (yielded) | `StopIteration` | raised exception |
Design every binding to this single table. Pull-shaped, success-only events,
terminal-by-`MessageComplete`, exhaustion-by-`null`, failure-by-error.
## Out of scope for v1 (deliberately deferred)
- **A pollable fd / `panto_step_poll(timeout)`.** This is the only thing that
makes async *natively* non-blocking on both Go (`select`) and Python
(`await` on readiness). It is real work in `libpanto-c` and unnecessary for
v1. **Design the C ABI so a pollable fd can be added later without breaking
the existing pull surface**, but do not build it now.
- **A native async Python API.** Without an fd, async Python is *just* the sync
pull API wrapped in `asyncio.to_thread`. That is pure-Python glue in
`panto/__init__.py`; no native or ABI work, so it isn't a binding deliverable.
- **Languages beyond Go and Python.** `libpanto-c` is the reuse point for any
future cgo-style or cffi-style consumer (Ruby, Node N-API, …); none are in
scope now.
## Open questions / decisions to finalize before coding
1. **`panto.h` generation — resolved: no.** The header is committed and
hand-maintained as the ABI contract; `build.zig` stages it but does not emit
it. (Captured in Phase 1, step 9.)
2. **Resumable-handle strategy (state machine vs. thread+queue)** — target the
state machine; decide finally once the provider-loop inversion is scoped.
3. **Exact `Event` variants** — finalize against `ReceiverVTable`
(`provider.zig`) and the tool-dispatch path (`agent.zig`).
4. **What moves onto `Agent` vs. stays per-`run()`** — driven by
`docs/pluggable-session-store.md`'s final shape; `conversation` is moving,
confirm nothing else needs to.
5. **CPython stable-ABI (`abi3`) commitment** — decide in Phase 3 to fix the
wheel matrix early.
|