summaryrefslogtreecommitdiff
path: root/docs/archive/image-reads.md
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-03 09:11:36 -0600
committert <t@tjp.lol>2026-06-03 12:34:13 -0600
commitac5c4898dfa0a9e57424336774893dfc72b132e9 (patch)
tree03eb8e32a001845dbaec163dfd4905ffc23d9fa6 /docs/archive/image-reads.md
parent2416a308c0e467f8dbdbe09942aa903bca751c0e (diff)
image uploads
Diffstat (limited to 'docs/archive/image-reads.md')
-rw-r--r--docs/archive/image-reads.md200
1 files changed, 200 insertions, 0 deletions
diff --git a/docs/archive/image-reads.md b/docs/archive/image-reads.md
new file mode 100644
index 0000000..ab65ee1
--- /dev/null
+++ b/docs/archive/image-reads.md
@@ -0,0 +1,200 @@
+# Plan: image (and file) upload support for tool results
+
+## Goal
+
+Let tools return binary attachments (images, PDFs) — not just text — so the
+`read` tool can hand image/PDF files to the model. Anthropic and OpenAI wrap
+such content differently in their request JSON, so the tool-result data model
+must grow from "one text string" to "a list of typed parts." This is a
+first-class libpanto (Zig/C) feature, not a Lua-only convenience.
+
+## Background / constraints
+
+- A Lua tool handler currently can only return a string
+ (`lua_bridge.readHandlerResult` rejects non-strings), and
+ `conversation.ToolResultBlock.content` is a single `TextualBlock`.
+- **Anthropic** allows `tool_result.content` to be an *array* of typed blocks
+ (`{type:"text"}`, `{type:"image",source:{type:"base64",media_type,data}}`,
+ and `{type:"document",...}` for PDFs).
+- **OpenAI Chat Completions** does **not** allow images in `tool` (or system /
+ assistant) messages — only in `user` messages. So a tool-returned image
+ cannot ride inside the `role:"tool"` message; it must be emitted as a
+ synthetic follow-up `role:"user"` message.
+- pi (reference impl) stores image data **inline as base64** in the session
+ log (no sidecar files) and treats each image as a fixed ~4800-char estimate
+ for compaction token math. We follow the same approach.
+
+## Locked decisions
+
+1. **Module graph flip.** `tool.zig` is the more atomic module; move `ToolDecl`
+ into `tool.zig` and have `tool_source.zig` import it from there (flips the
+ current `tool_source -> tool` edge to `tool -> ... ` removed; new edge is
+ `tool_source -> tool`). The new `ResultPart` type also lives in `tool.zig`.
+ `conversation.zig` (currently import-free) imports `ResultPart` from
+ `tool.zig`. Verify no cycle: `tool.zig` must not import `conversation.zig`.
+
+2. **One `ResultPart` type, two roles.** `ResultPart` is both:
+ - the element type of `Tool.invoke`'s return value (the widened contract), and
+ - the in-memory storage in `ToolResultBlock.parts: []ResultPart`
+ (replacing the single `content: TextualBlock`).
+
+3. **Contract widening (native Zig/C feature).**
+ - `Tool.invoke` returns `[]ResultPart` instead of `[]u8`.
+ - `ToolSource` `CallResult.ok` carries `[]ResultPart` instead of `[]u8`.
+ - Every native tool and adapter updates to the new return shape.
+
+4. **Detection: magic bytes** (not file extension). Detect PNG / JPEG / GIF /
+ BMP / WEBP / PDF from leading bytes; this drives both "is this an
+ attachment?" and the `media_type` string.
+
+5. **Resize: libpanto-native, 2000x2000 max, skip when already small.**
+ - stb single-header trio (`stb_image.h`, `stb_image_resize2.h`,
+ `stb_image_write.h`) for JPEG / PNG / GIF / BMP — **preserve input codec**
+ on re-encode.
+ - `jebp.h` (single-header, **decode-only**) for WEBP. Resized WEBP is
+ re-encoded as **JPEG (~q80)** since there is no small single-header WEBP
+ encoder and fidelity is secondary to token size for LLM input.
+ - **TODO comment** at the WEBP re-encode site: consider re-encoding to PNG
+ instead of JPEG when the source WEBP has an alpha layer (JPEG has no
+ transparency; flattening alpha can look wrong for screenshots/diagrams).
+ - PDFs pass through unresized.
+ - Skip the decode/resize/encode round-trip entirely when both dimensions
+ are already <= 2000 (avoid quality loss + CPU).
+
+6. **Storage: inline base64** (pi-style), no sidecar files. Compaction sizes
+ each image as a fixed ~4800-char estimate so base64 blobs don't distort the
+ retention window.
+
+## ResultPart shape (proposed)
+
+```zig
+// in tool.zig
+pub const MediaPart = struct {
+ media_type: []const u8, // e.g. "image/png", "application/pdf"
+ data: []const u8, // base64-encoded bytes
+};
+
+pub const ResultPart = union(enum) {
+ text: []const u8,
+ media: MediaPart,
+};
+```
+
+In-memory conversation storage uses the streaming `TextualBlock` form for the
+text part to preserve incremental-append semantics:
+
+```zig
+// in conversation.zig
+pub const ResultPartStored = union(enum) {
+ text: TextualBlock,
+ media: struct { media_type: []const u8, data: TextualBlock },
+};
+pub const ToolResultBlock = struct {
+ tool_use_id: []const u8,
+ parts: std.ArrayList(ResultPartStored),
+ // ... deinit frees tool_use_id + every part
+};
+```
+
+(Exact split between the borrowed `ResultPart` contract type and the owned
+stored type to be finalized during implementation — the key constraint is
+`Tool.invoke` returns owned bytes the agent takes ownership of.)
+
+## Work breakdown (dependency order)
+
+### 1. Module graph + types (`tool.zig`, `tool_source.zig`, `conversation.zig`)
+- Move `ToolDecl` from `tool_source.zig` to `tool.zig`; update the import edge.
+- Add `MediaPart` + `ResultPart` to `tool.zig`.
+- Rework `ToolResultBlock` in `conversation.zig` to hold `parts`. Update its
+ `deinit`. Update `cloneBlock` (agent.zig ~line 86).
+
+### 2. Widen the native contract (`tool.zig`, `tool_source.zig`)
+- `Tool.invoke` return: `[]u8` -> `[]ResultPart`.
+- `ToolSource.CallResult.ok`: `[]u8` -> `[]ResultPart`.
+- Update vtable doc comments (ownership of part bytes transfers to the agent).
+
+### 3. Agent assembly (`agent.zig` ~line 550-595)
+- Build `ToolResultBlock.parts` from the returned `[]ResultPart` instead of
+ copying a single byte slice into `content`.
+- `FlatCall.result` type follows the contract change.
+
+### 4. Lua bridge (`lua_bridge.zig`, `lua_runtime.zig`)
+- `readHandlerResult`: accept a string (-> one `text` part) OR a table
+ `{ text = "...", attachments = { { media_type = "...", data = "..." }, ... } }`
+ (-> one optional text part + media parts).
+- Thread parts through `Slot` / `recordResultC` / the legacy sync path
+ (`invokeCoroutineSync`) instead of `[]u8`.
+
+### 5. Native image processing (libpanto, new module e.g. `image.zig` + C deps)
+- Vendor stb headers + `jebp.h`; wire into `libpanto/build.zig` and
+ `build.zig.zon` (matching the existing C-dep vendoring pattern).
+- `detectMediaType(bytes) -> ?[]const u8` via magic bytes.
+- `maybeResize(bytes, media_type) -> owned bytes`:
+ - non-raster (PDF) or already <=2000x2000: return as-is.
+ - stb-supported: decode -> resize (stb_image_resize2, Mitchell) -> re-encode
+ in same codec (stb_image_write).
+ - WEBP: jebp decode -> resize -> JPEG ~q80 (with the alpha-layer TODO).
+- The `read` tool calls this before base64-encoding.
+
+### 6. Serializers
+- **Anthropic** (`anthropic_messages_json.zig`, `ToolResult` branch): emit
+ `content` as an array — `{type:"text"}` for text parts; `{type:"image",
+ source:{type:"base64",media_type,data}}` for image media; `{type:"document",
+ source:{...}}` for PDFs.
+- **OpenAI** (`openai_chat_json.zig`): `role:"tool"` message carries text parts
+ only (plus a short placeholder note when media exists); emit a synthetic
+ follow-up `role:"user"` message whose content array holds `image_url`
+ data-URL parts for the media.
+
+### 7. Session round-trip (`session.zig`)
+- `DiskToolResultBlock` gains a `parts` array; `writeDiskBlock` /
+ `parseDiskBlock` handle text + media (`{type:"image",mimeType,data}` style).
+ Inline base64, no sidecar.
+- Round-trip tests for a tool result containing text + an image part.
+
+### 8. Compaction (`compaction.zig` / wherever tool-result size is estimated)
+- Count a fixed ~4800-char estimate per image part rather than base64 length,
+ so images don't distort the retention window. **Verify** the exact site
+ where pantograph sizes tool-result content.
+
+### 9. read tool (`agent/tools/read.lua`)
+- After reading bytes, magic-byte detect. On a recognized binary type, hand the
+ raw bytes to the libpanto image path (resize happens natively), base64-encode
+ the result, and return `{ attachments = { { media_type=..., data=... } } }`.
+ Text path unchanged.
+- NOTE: detection + resize is native; the Lua side mostly forwards bytes and
+ receives processed bytes back. Exact Lua<->libpanto surface for invoking the
+ native image step to be settled in step 5 (a new `panto.*` bridge fn vs.
+ doing detection/resize entirely inside a native read tool).
+
+## Open items — RESOLVED during implementation
+- Owned-vs-borrowed split: `tool.ResultPart` (contract) borrows-then-transfers
+ owned bytes. `media: { media_type: ?[]const u8, data: []const u8 }` carries
+ **raw, un-encoded file bytes**; `media_type` is an optional hint. The agent
+ frees via `tool.freeResultParts`. The stored conversation type
+ `conversation.ResultPartStored` uses `TextualBlock` for text (append
+ semantics) and resolved `media_type` + base64 `TextualBlock` data for media.
+- **libpanto does the heavy lifting, tools stay dumb.** Tools return raw bytes
+ (optional `media_type` hint). At tool-result assembly (`agent.zig`
+ `dispatchToolCalls`), libpanto calls `image.process(bytes, hint)` which
+ magic-byte-detects the type when the hint is absent, resizes large rasters,
+ and the agent base64-encodes the result for storage. There is **no**
+ `panto.process_image` bridge — `read.lua` only does a minimal magic-byte
+ sniff (`is_attachment`) to choose binary-vs-text, then returns raw bytes.
+ Unrecognized bytes are dropped with a text note rather than aborting the turn.
+- WEBP-with-alpha: still re-encodes to JPEG (TODO comment retained at the
+ re-encode site in `image.zig`).
+
+## Status
+All 9 work-breakdown steps implemented. C deps vendored under
+`libpanto/src/cdeps/` (stb trio + jebp), compiled via `image_impl.c` wired into
+`libpanto/build.zig`. `zig build` + `zig build test` green at both the libpanto
+and top-level layers.
+
+## Test plan
+- Unit: magic-byte detection; resize skip vs. apply; codec preservation;
+ WEBP->JPEG path.
+- Bridge: handler returning string; handler returning `{text,attachments}`.
+- Serializers: Anthropic block-array shape; OpenAI tool+synthetic-user split.
+- Session: round-trip text+image tool result.
+- Contract: a native Zig tool returning `[]ResultPart` with a media part.