diff options
| author | t <t@tjp.lol> | 2026-06-06 12:24:37 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-07 10:51:16 -0600 |
| commit | 032d27b18597ee3f10222963b53625208c84f571 (patch) | |
| tree | e192ac678d0859e7ea00fb4310e75cf02470659b /docs | |
| parent | 73645129a9de90f867908d35e77c9252bae4e534 (diff) | |
lib cleanup doc
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/libpanto-cleanup.md | 580 |
1 files changed, 580 insertions, 0 deletions
diff --git a/docs/libpanto-cleanup.md b/docs/libpanto-cleanup.md new file mode 100644 index 0000000..9906091 --- /dev/null +++ b/docs/libpanto-cleanup.md @@ -0,0 +1,580 @@ +# Plan: `libpanto` public API cleanup + +## Goal + +Define a small, deliberate public API for `libpanto` — the surface that the +language bindings (`libpanto-c`, `libpanto-go`, `libpanto-py`, see +`docs/libpanto-bindings.md`) and any Zig embedder wrap. Today the public +surface is *accidental*: `root.zig` re-exports ~18 modules wholesale, so every +`pub` declaration (≈180 of them) is reachable, including types that are `pub` +only to cross Zig file boundaries (`SSEParser`, `EventQueue`, the `Disk*` wire +types, provider-loop internals). The smaller the surface, the better. + +This is a **prerequisite for Phase 1** of the bindings work: you cannot wrap a +C ABI cleanly over an API whose boundary is undefined. + +## Guiding principle: the public API is additive, expressed in one file + +We do **not** rewrite the internals. Almost everything we want already exists. +The cleanup is overwhelmingly *additive*: + +- A new file, **`public.zig`**, is the sole exported root. `build.zig` points + the `panto` module at `public.zig` instead of `root.zig`. Internal modules + keep their `pub` decls (still needed for cross-file linking) but are simply + **never re-exported**. The public surface becomes an explicit allowlist in + one file, not "whatever happens to be `pub`." +- Where we want a different *name* or a *narrower* shape than the internal + type, `public.zig` provides a thin façade (see below). The internal + implementation is untouched. +- Net-new convenience functions (e.g. `panto.init` wrapping the existing + `initHttp`) live in `public.zig` and call through to the unchanged internals. + +The result: most of this plan is "write `public.zig`," with a **single genuine +internal refactor** (tool registry off `Config`, see Refactor R1) plus a +**session-format change** we've wanted anyway (Refactor R2). + +## The façade mechanism (how a type exposes 1/5 of its fields) + +Zig has no `private` keyword. The idiomatic way to hide internal fields is a +**wrapper type holding one pointer** to the internal struct, re-exposing only +the methods/fields we want: + +```zig +// public.zig +const internal = @import("agent.zig"); + +pub const Agent = struct { + inner: *internal.Agent, + pub fn run(self: Agent, msg: UserMessage) !Stream { + return .{ .inner = try self.inner.run(.{ .text = msg.text }) }; + } + // ...one forwarder per public method +}; +``` + +Consumers hold `Agent` (the wrapper); its only field is `inner`, whose type +(`internal.Agent`) is not re-exported, so the internal fields are unreachable. +Renames are free: `pub const State = internal.Stream.Phase;` or an accessor +`pub fn state(self: Stream) State { return self.inner.phase; }` renames without +touching `agent.zig`. + +### Three buckets — wrap, handle, or alias + +| bucket | rule | types | +| --- | --- | --- | +| **behavioral** | wrap a **pointer** to a heap-pinned internal | `Agent`, `Stream`, `Conversation` | +| **data (read/output)** | **alias** straight through; inspected field-by-field | `Event` (+ nested), `Usage`, `Pricing`, `SessionInfo` | +| **data (constructed)** | **alias** straight through; Zig users build them with `ArrayList` directly | `ContentBlock`, `Message`, the block types, `Config` family, `ResultPart` | + +**Why pointer-wrap all three behavioral types** (not value-wrap): + +- A value wrapper containing `internal.Agent` inline makes "is the inner + movable?" an invariant you must keep true forever. It already isn't safe to + move after `run()`: `Stream` holds `*Agent`. Nothing stops a Zig embedder + from `var a2 = a1;`. +- Pointer-wrapping makes the invariant **structural**: the inner is + heap-pinned at `init`, the wrapper is a cheap copyable handle, "don't move + the Agent" stops being a rule anyone can violate. +- The indirection cost is one dereference per *coarse* call (`run`, `compact`, + `registerTool` — all do I/O or allocation). Unmeasurable. +- The C ABI heap-boxes the handle regardless, so the "save an `init` malloc" + argument for value-wrapping is illusory. + +**Handle types are minted per-call for free.** `Agent.conversation()` returns +a `Conversation` wrapper that copies one pointer (`&self.inner.conversation`) +— no rebuild, no copy of conversation data. A `Conversation` handle borrows +state owned by its `Agent` and has the agent's lifetime. + +**Why alias the constructed data types** rather than build slice-based +façades: for a *Zig* API, `ArrayList` is well understood and exposing it is +fine. Conversation surgery (custom compaction, context management) wants the +real types, transparently. The slice-based constructor ergonomics that a C +caller needs are `libpanto-c`'s job — and building them once there covers the +majority of language bindings. (`libpanto-py` is Zig too, so `ArrayList` is +natural there as well.) + +--- + +## The two jobs the API must serve + +Everything below is derived from two user jobs, not from "the CLI happens to +call it." + +1. **Run an agent loop** — configure a provider, register tools, submit a + turn, consume the pull event stream, persist. +2. **Construct & operate on conversations** — build a `Conversation` by hand, + insert/modify/delete content blocks, implement custom compaction or other + context-management surgery. + +If something serves neither job, it stays internal. + +--- + +## Target surface + +### Process lifecycle + +```zig +pub fn init(allocator: std.mem.Allocator, io: Io) void // wraps initHttp +pub fn deinit() void // wraps deinitHttp +``` + +`httpClient` is **not** exposed (internal plumbing). `initHttp`/`deinitHttp` +stay `pub` internally but are reached only through `init`/`deinit`. + +### Config (data, aliased) + +```zig +pub const Config = config.Config; // see Refactor R1 — registry leaves it +pub const ProviderConfig = config.ProviderConfig; +pub const OpenAIChatConfig = config.OpenAIChatConfig; +pub const AnthropicMessagesConfig = config.AnthropicMessagesConfig; +pub const APIStyle = config.APIStyle; +pub const ReasoningEffort = config.ReasoningEffort; +pub const CompactionConfig = config.CompactionConfig; +pub const RetryConfig = config.RetryConfig; +``` + +### Agent (behavioral, pointer-wrapped) + +```zig +pub const Agent = struct { + inner: *internal.Agent, + + pub fn init(allocator, io, config: *const Config, store: SessionStore, maybe_conversation: ?Conversation) !Agent; + pub fn deinit(self: Agent) void; + // Note: no registry parameter (R1). A fresh Agent starts with an empty + // tool set; populate it with the register* methods below. + + pub fn registerTool(self: Agent, tool: Tool) !void; // R1: registry now on Agent + pub fn registerToolSource(self: Agent, src: ToolSource) !void; + pub fn setConfig(self: Agent, config: *const Config) void; // swap provider/model between turns + + pub fn run(self: Agent, message: UserMessage) !Stream; + pub fn compact(self: Agent, system_prompt: []const u8, extra: ?[]const u8) !CompactionResult; // persists + + pub fn conversation(self: Agent) Conversation; // borrowed handle +}; + +pub const UserMessage = struct { text: []const u8 }; // top-level, NOT nested in Agent +pub const CompactionResult = agent.Agent.CompactionResult; // aliased data +``` + +Changes from today: + +- **`registerTool` / `registerToolSource` move onto `Agent`** (Refactor R1). + The tool set is no longer part of `Config`; swapping provider/model no + longer means rebuilding the whole tool list. +- **`UserMessage` is top-level**, defined in `public.zig` (translated to the + internal `agent.Agent.UserMessage` inside `run`). +- **`Agent.addSystemMessage` is dropped** — system messages are a + `Conversation` concern (see below). +- **`compactAndPersist` → `compact`** (persists by default). The pure + no-persist transform stays a private internal helper. +- **`registry()` / `httpClient`-style accessors not exposed.** + +### Stream (behavioral, pointer-wrapped) + +```zig +pub const Stream = struct { + inner: *internal.Stream, + pub fn next(self: Stream) !?Event; + pub fn deinit(self: Stream) void; + pub fn state(self: Stream) State; // renamed from internal `phase` +}; +pub const State = internal.Stream.Phase; // renamed: Phase -> State, transparent state machine +``` + +`next`/`deinit` are the streaming contract from `docs/libpanto-bindings.md` +(`!?Event`: value = progress incl. terminal, `null` = exhausted, `error` = +failure). `state` is newly *exposed* (was a private field) and renamed to make +the state machine transparent. + +### Event (data, aliased) + +```zig +pub const Event = stream.Event; // union(enum), nested structs aliased with it +``` + +Aliased wholesale — `Event` and its nested structs (`BlockStart`, +`ToolDetails`, `ContentDelta`, `BlockComplete`, `MessageComplete`, +`ToolDispatchStart`, `ToolDispatchComplete`) are read-only output, inspected +field-by-field. The nested-struct-inside-`Event` organization is good and +stays. (Wrapping a tagged union by hand would be miserable and pointless.) + +Supporting data types referenced by `Event`: + +```zig +pub const ContentBlockType = provider.ContentBlockType; +pub const ProviderRetryInfo = provider.ProviderRetryInfo; // payload of provider_retry +``` + +### Conversation construction (data, aliased — Job 2) + +The full block/message/conversation surface, aliased straight through so Zig +users can build and operate on conversations with the real `ArrayList`-backed +types: + +```zig +pub const Conversation = struct { // behavioral handle (pointer) + inner: *internal.Conversation, + // metadata: ?[]const u8 — conversation-level user bag, fixed at creation + // (init), persisted in the session header. JSON-string contract (below). + pub fn metadata(self) ?[]const u8; // read the header bag + pub fn addUserMessage(self, text) !void; + pub fn addAssistantMessage(self, blocks: []const ContentBlock, usage: ?Usage) !void; // merged + pub fn addSystemMessage(self, text) !void; + pub fn replaceSystemMessage(self, text) !void; + pub fn addCompactionSummary(self, text) !void; + pub fn messages(self) []Message; // direct access for surgery + // deinit only when standalone-owned; a borrowed handle from Agent does not own +}; + +// Block/message data types — aliased, constructed directly with ArrayList: +pub const ContentBlock = conversation.ContentBlock; +pub const Message = conversation.Message; // carries `metadata: ?[]const u8` (JSON bag) +pub const MessageRole = conversation.MessageRole; +pub const TextualBlock = conversation.TextualBlock; +pub const ThinkingBlock = conversation.ThinkingBlock; +pub const ToolUseBlock = conversation.ToolUseBlock; +pub const ToolResultBlock = conversation.ToolResultBlock; +pub const ResultPartStored = conversation.ResultPartStored; +pub const StoredMediaPart = conversation.StoredMediaPart; +pub const SystemBlock = conversation.SystemBlock; +pub const SystemMode = conversation.SystemMode; +pub const CompactionSummaryBlock = conversation.CompactionSummaryBlock; +pub const Usage = conversation.Usage; +pub const effectiveSystemBlocks = conversation.effectiveSystemBlocks; // replay helper +``` + +#### Per-message and per-conversation metadata (the JSON bag) + +To support custom history strategies (e.g. a "live compaction" scheme that +tracks a parallel side-conversation against a smaller model and dynamically +rebuilds history each turn), `libpanto` carries two opaque metadata bags it +never interprets: + +- **`Message.metadata: ?[]const u8 = null`** — attached per message, round-trips + through persistence: set it before a turn commits, read it off the `Message` + after `load`. (e.g. a live message stores the ids of the side-conversation + summary messages that represent it.) +- **`Conversation.metadata: ?[]const u8`** — conversation-scoped, **fixed at + creation time** (`Conversation.init`), stored in the session **header**. + (e.g. the pointer to the whole side-conversation.) It is set once and not + mutated, which is exactly what an append-only header supports. + +**Contract:** both are pure `[]const u8` throughout `libpanto`'s in-memory and +wire modeling — the library treats them as opaque bytes and never parses them. +The documented requirement on *users* is that, when present, the bytes are +valid JSON (so a store may store them as a JSON column and tools may +deserialize them). `libpanto` does not validate this. + +Changes: + +- **`addAssistantMessageWithUsage` → `addAssistantMessage(blocks, ?Usage)`** — + one method; pass `null` for no usage. The separate method is deleted. +- **System messages live on `Conversation` only** (not `Agent`). The old + `Agent.addSystemMessage` also persisted; persistence is now strictly the + `Agent` turn-path's job. A bare `Conversation` mutation is in-memory until a + turn commits. This is sound for the append-only log because **every + `PersistentMessage` carries the full current `Conversation`** (see Sessions), + so whatever state a turn was built on is captured at write time regardless of + prior in-memory surgery. System-prompt changes specifically are expressed via + `replaceSystemMessage` (the `.replace` `SystemMode`), which the log records + faithfully. + +### Tools (data + small façade — Job 1) + +```zig +pub const Tool = tool.Tool; +pub const ToolSource = tool_source.ToolSource; +pub const ToolDecl = tool.ToolDecl; +pub const ToolCall = tool_source.Call; +pub const ToolCallResult = tool_source.CallResult; +pub const MediaPart = tool.MediaPart; + +// Result-part ergonomics — replace the freestanding trio with a thin struct +// wrapper around the slice (a bare `[]ResultPart` alias can't carry methods): +pub const ResultPart = tool.ResultPart; +pub const ResultParts = struct { + items: []ResultPart, + pub fn fromText(alloc, text: []const u8) !ResultParts; // was textResult + pub fn fromTextOwned(alloc, text: []u8) !ResultParts; // was ownedTextResult + pub fn deinit(self: ResultParts, alloc) void; // was freeResultParts +}; +``` + +Changes: + +- **Drop the freestanding `textResult` / `ownedTextResult` / + `freeResultParts`** in favor of a `ResultParts` struct wrapping the slice, + with `fromText` / `fromTextOwned` constructors and a `deinit` method. +- **`ToolRegistry` is hidden.** It's an internal organization structure; after + R1 the only public way to populate an agent's tool set is + `Agent.registerTool` / `registerToolSource`. Users no longer pre-build a + registry to seed `Agent.init`. + +> **Deferred:** removing a tool from an active `Agent`. Today the workaround is +> to build a fresh `Agent` with the same config + conversation and re-register +> the desired tools. A future `Agent.unregisterTool(name)` convenience is out +> of scope for this project. + +### Pricing (data, aliased — Job 1) + +```zig +pub const Pricing = pricing.Pricing; +pub const PricingRegistry = pricing.Registry; +// cost becomes a method: +// Pricing.cost(self, usage: Usage) ?u64 (was freestanding costMicroCents(usage, pricing)) +``` + +Change: **`costMicroCents` → `Pricing.cost(usage)`** — namespaced on the rate +card (pricing is the rates, usage is the input). + +### Sessions (Refactor R2 — redesigned) + +A clean re-think. The interface is **asymmetric**: rich on write +(audit/provenance-capable), minimal on read (resume-oriented). The store +decides how much write-side richness it durably keeps. + +```zig +pub const PersistentMessage = struct { // the rich, audit-oriented write type + message: Message, // the message being appended (carries its own metadata) + usage: ?Usage, + // wire-format provider identity (no CLI aliases, and NO api_key material + // of any kind — not even a hash; keys never enter libpanto's records): + api_style: APIStyle, + base_url: []const u8, + model: []const u8, + reasoning: ReasoningEffort, + // full provenance context, captured every append: + conversation: []const Message, // the entire current conversation at write time + tools_available: []const ToolDecl, // the tool set offered for this turn +}; +``` + +```zig +pub const SessionStore = struct { // vtable interface; impls own their own init (DSN, dir, ...) + ptr: *anyopaque, + vtable: *const VTable, + pub const VTable = struct { + create: *const fn (ctx: *anyopaque) Session, + list: *const fn (ctx: *anyopaque) anyerror![]SessionInfo, + freeSessionInfos:*const fn (ctx: *anyopaque, infos: []SessionInfo) void, + resolve: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Session, + latest: *const fn (ctx: *anyopaque) anyerror!?Session, + load: *const fn (ctx: *anyopaque, id: []const u8) anyerror!?Conversation, + appendMessages: *const fn (ctx: *anyopaque, session_id: []const u8, messages: []PersistentMessage) anyerror!void, + }; + // thin proxies for each +}; + +pub const NullStore = ...; // persists nothing; empty/no-op impl of every method +pub const FileSystemJSONLStore = ...; // renamed from SessionManager; directory-backed JSONL + +pub const SessionInfo = struct { // pure data, aliased; for display/selection (`panto sessions`) + id: []const u8, + created: []const u8, + modified: []const u8, + message_count: usize, + last_user_message: []const u8, // may be truncated + // last-used wire identity, updated on append (for resume pre-selection): + api_style: APIStyle, + base_url: []const u8, + model: []const u8, // wire model name, NOT a CLI config alias + reasoning: ReasoningEffort, // disambiguates otherwise-identical aliases +}; + +pub const Session = struct { // pure data: SessionInfo + a store to proxy to + info: SessionInfo, + store: SessionStore, + pub fn load(self: Session) !Conversation; // by value; null->error (id came from resolve/latest) + pub fn append(self: *Session, messages: []PersistentMessage) !void; // *Session: updates info.{api_style,base_url,model,reasoning} +}; +``` + +Design decisions: + +- **No allocator/io in vtable signatures.** A store captures whatever it needs + at its own (unprescribed) `init` — a Postgres store takes a DSN, the FS store + takes a directory. Returned `Conversation` self-describes its allocator; + `[]SessionInfo` is freed via the `freeSessionInfos` vtable method (rather + than struct-wrapping the slice to carry an allocator). +- **`create` returns `Session` (not `!Session`)** — minting an in-memory handle + can't fail. Create-on-demand: nothing hits the backend until the first + `appendMessages`, preserving the "no record before the first assistant + message" invariant. +- **`Session.load` returns `!Conversation` by value, optional dropped** — the + id came from `resolve`/`latest`, so the conversation must exist; a `null` + from the store is promoted to an error. (`Conversation` is a small movable + struct; the `ArrayList` buffer is the only heap part. No `*Conversation`.) +- **No `activeModel` vtable method.** The last-used wire identity lives on + `SessionInfo` and is updated by `Session.append` after it proxies to the + store — hence `append` takes `*Session`. +- **All session identity is wire-format**, never CLI config aliases: + `{api_style, base_url, model, reasoning}`. (See R2 for the format change.) + +#### The write/read asymmetry, and provenance + +- **Write (`appendMessages` / `PersistentMessage`) is maximalist** — the rich, + audit-oriented type. It carries full wire identity, usage, content, the + **entire current conversation**, and the **tool set offered** for the turn. + The library **offers** all of it on every append; the store keeps what it + wants. This makes `libpanto` provenance-*capable* for compliance-concerned + consumers who implement their own store. +- **`api_key` material never enters a record** — not the key, not a hash of it. + Library-level guarantee, not a per-store choice. (Hashing keys is a + deliberate non-goal: it's needless risk in `libpanto` core.) +- **Read (`load`, `SessionInfo`) is minimal** — exactly what resume and + selection need. The vtable never demands a store reproduce provenance on + read, because a store may not have kept it. The read path deserializes + `Conversation` + `SessionInfo`, never a `PersistentMessage`. +- **`FileSystemJSONLStore` deliberately stays minimal.** It ignores the + `conversation` and `tools_available` provenance fields and simply appends the + latest `message` (plus wire identity + usage). The rich fields exist for + *other* stores; the built-in FS store is not an audit store. + +> **Audit-store implementation note (for `SessionStore` docs).** A serious +> audit log should **not** store the full `tools_available` (and likely not the +> full `conversation`) verbatim on every record — that re-serializes the whole +> tool manifest each turn. The recommended design is **content-addressing**: +> hash each tool's `(name, description, schema_json)`, keep a deduplicated tool +> table keyed by that hash, and store only the hash list on each record, +> normalizing the log by joining records against the tool table. The same +> applies to large repeated context. `libpanto` hands you the raw data each +> append; efficient de-duplication is the store's responsibility. + +#### Provider/model pre-selection on load + +The session log stores **wire facts only** (`{api_style, base_url, model, +reasoning}`), never config aliases. Pre-selecting "the provider/model they left +off with" is a **lookup against current config at load time**, not a stored +pointer: + +- A helper `match(config, session_info) -> []candidate` walks configured + providers/aliases and returns every alias whose `(api_style, base_url, model, + reasoning)` equals the session stamp. +- 1 candidate → pre-select. 0 → readable, no pre-selection (prompt / read-only). + N (e.g. two API keys for the same endpoint, or two reasoning levels) → + embedder picks (default / first / prompt). + +Storing the alias instead would freeze stale config state (aliases get renamed) +and *still* fail the N-match case (two keys are indistinguishable on the wire). +Recording `reasoning` as a wire field is what splits otherwise-identical +aliases. The CLI owns the pick; `libpanto` may provide `match` as a helper over +`Config`. + +--- + +## Refactors (the non-additive work) + +Everything else is `public.zig`. These two are genuine internal edits. + +### R1 — tool registry off `Config`, onto `Agent` + +Today `Config.registry: *const ToolRegistry` and `Agent.run` reads +`config.registry`. Consequence: swapping config (provider/model change) forces +rebuilding the tool set. Move tool-set ownership to `Agent`: + +- `Agent` owns/holds the `ToolRegistry` (or borrows one supplied at `init`). +- `Agent.registerTool` / `registerToolSource` mutate it. +- `Config` carries only provider/model/retry/compaction; `setConfig` swaps + those without touching tools. +- The agent loop reads the registry from `self`, not `self.config`. + +This is the one change a façade can't paper over — it relocates *where the +running loop reads the tool set from*. + +### R2 — session-log format: wire-format identity, no config aliases + +The on-disk session format records **wire-format** provider/model identity +(`api_style`, `base_url`, `model`, `reasoning`), replacing today's single +`provider` string and any CLI-alias coupling. `Disk*` types are renamed to +`Persistent*` (e.g. `DiskMessage` → `PersistentMessage`), and +`PersistentMessage` carries its own `provider`/`model` wire identity rather +than the parallel `providers[]`/`models[]` slices the current +`appendMessages` takes. + +**Clean break, no version bump.** Pantograph has effectively a single user +(the author), so existing logs are simply wiped rather than migrated. No +compat shim, and `CURRENT_VERSION` need not change. + +--- + +## Internals that leave the public surface (derived deletions) + +Once `public.zig` is the allowlist, these stop being reachable (they stay `pub` +internally for linking, just un-re-exported): + +- **`provider.zig`**: `ProviderStream`, `openStream`, `OpenStreamFn`, + `ProviderDiagnostic`, `ProviderError`, `isContextOverflowBody`, + `classifyHttpStatus`, `parseRetryAfterMs`, `retryAfterFromHead`, + `isRetryableProviderError`. (`ContentBlockType`, `ProviderRetryInfo` stay — + referenced by `Event`.) +- **`stream.zig`**: `EventQueue` (internal plumbing; only `Event` escapes). +- **`sse.zig`**: `SSEParser` (pure internal). +- **`session.zig`**: the serialization layer — `serializeHeader`, + `serializeEntry`, `parseLine`, `contentBlockToDisk`, + `diskContentBlockToInternal`, `SessionHeader`, `SessionEntry`, `EntryBase`, + `MessageEntry`, `FileEntry`, `ParseError`, `CURRENT_VERSION`. The + `Persistent*` (ex-`Disk*`) content types are referenced by + `PersistentMessage` on the write path; expose only what `appendMessages` + needs. +- **`turn_persist.zig`**: `persistTurn`, `persistCompaction`, + `hasToolUseWithoutFollowingResults` (internal to the agent/session loop). +- **`compaction.zig`**: `computeSplit`, `serializeTranscript`, + `buildRequestBody`, `messageTokenEstimate`, `Split`, `word_to_token_factor`, + `latestSummaryText` (internals; `Agent.compact` is the entry). +- **`tool_registry.zig`**: name-encoding helpers (`validateName`, `encodeName`, + `decodeName`, `max_wire_name_len`, `NameError`, `Entry`, `ToolView`, + iterators) — `ToolRegistry` itself stays, its internals don't. +- **`config.zig`**: `httpClient` (and `initHttp`/`deinitHttp` only reachable via + `init`/`deinit`). +- **`image.zig`**: `process`, `maybeResize`, `detectCodec`, `detectMediaType`, + `codecForMediaType`, `Codec`, `Processed`, `max_dim` — used internally at + tool-result assembly; not a Job-1/Job-2 concern. **Hidden.** Note the + user-visible *behavior* must still be documented: tool-returned images have a + maximum longer-side dimension and `libpanto` resizes larger ones down before + storage/serialization (see the deferred config-setting item above). +- **`session_manager.zig` freestanding utils**: `newUuidV7`, `isoTimestamp` + (id/timestamp minting — internal to whichever store wants them). + +--- + +## Out of scope (deliberately deferred) + +- **A built-in audit/provenance *store*.** Provenance *capability* is in scope + — `PersistentMessage` is rich (full conversation + tool manifest + wire + identity) so a consumer's store can capture it. But `libpanto` does not ship + an audit store; `FileSystemJSONLStore` stays minimal. The content-addressing + design above is a recommendation in the `SessionStore` docs, not code we + write here. +- **Slice-based block/message constructors for non-Zig callers.** That's + `libpanto-c`'s job (covers Go and other cgo/cffi consumers in one place). +- **The provider-config `match` helper UI.** `libpanto` may ship `match`; the + pick policy (default/first/prompt) is the embedder's. +- **Configurable image max-dimension.** `libpanto` resizes oversized images + down to a maximum longer-side dimension (today a hard-coded `max_dim` in + `image.zig`). This should become a `libpanto` config setting (single int, + longer side), with the `panto` CLI supplying the value instead of the value + living in the library. Deferred; for now the behavior is documented (see + below) but the constant stays put. +- **`Agent.unregisterTool`.** Removing a tool from a live agent; today rebuild + a fresh `Agent`. Deferred convenience. + +--- + +## Resolved decisions (formerly open) + +1. **Conversation-level persistence — Agent-only.** A bare `Conversation` + mutation is in-memory; persistence is strictly the `Agent` turn-path's job. + Append-only provenance is preserved because every `PersistentMessage` + carries the full current conversation at write time. +2. **`image.zig` — hidden.** Resize behavior is documented; a configurable + max-dimension is deferred (see Out of Scope). +3. **R2 migration — clean break, no version bump, no shim.** Wipe old logs. +4. **`ToolRegistry` — hidden.** `Agent.registerTool` / `registerToolSource` is + the only public path; no pre-built registry seeds `Agent.init`. + `Agent.unregisterTool` deferred (see Out of Scope). +5. **`ResultParts` — thin struct wrapper** around `[]ResultPart`, carrying + `fromText` / `fromTextOwned` / `deinit`. |
