diff options
| author | T <t@tjp.lol> | 2026-05-27 08:04:04 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-27 11:46:52 -0600 |
| commit | 576891dc2ec4d917932a4c396471d4bbbad90c8e (patch) | |
| tree | 0662d629cf15a2e9cbb51353f6d3abe6d2c6edb5 /docs/archive/phase-2.md | |
| parent | b72a405534d6be019573ee0a806014e2713fe55e (diff) | |
Finish the hard parts of the lua makeover
- bundle luarocks source in the panto binary
- bootstrap process (intended for first `panto` run):
- make ~/.local/share/panto/...
- write out luarocks sources into it
- run luarocks to install luv
- new `panto bootstrap` command just runs the bootstrap
- `panto bootstrap --force` removes everything and re-bootstraps
- new `panto lua` command just runs panto's embedded lua
Diffstat (limited to 'docs/archive/phase-2.md')
| -rw-r--r-- | docs/archive/phase-2.md | 157 |
1 files changed, 157 insertions, 0 deletions
diff --git a/docs/archive/phase-2.md b/docs/archive/phase-2.md new file mode 100644 index 0000000..4cb7ed9 --- /dev/null +++ b/docs/archive/phase-2.md @@ -0,0 +1,157 @@ +# Phase 2: Anthropic Provider + +**Status: complete.** Anthropic Messages API streams end-to-end alongside the phase-1 OpenAI provider; the Receiver callback sequence is identical across both; system prompts extract to the top-level field; thinking blocks round-trip via captured `signature` deltas. Refinements that diverged from the original plan: file/type names use the explicit dialect (`provider_anthropic_messages`, `AnthropicMessagesConfig`) to leave room for `openai_responses` and future Anthropic shapes; `Config` is a tagged union keyed by `APIStyle` rather than separate types at call sites; concrete provider types are internal — callers construct via `provider.Provider.init(allocator, io, config)`. Wire-level gotchas worth remembering: both providers send `accept-encoding: identity` because gzip buffers small SSE frames and defeats streaming; the read loop uses `readVec` rather than `readSliceShort` because the latter fills its buffer before returning, which also defeats streaming. Tool use / tool result blocks remain stubbed for phase 3+. + +## Goal + +Add Anthropic as a second provider, validating that the Provider abstraction and internal conversation model are genuinely provider-agnostic — not just OpenAI in disguise. + +## Deliverable + +A working `provider_anthropic.zig` that can hold a streaming conversation via Anthropic's API. At the end of this phase, you can: + +- Switch between OpenAI and Anthropic providers with no changes to the agent loop or conversation model. +- Stream responses from either provider and see the same callback sequence (onMessageStart, onBlockStart, onContentDelta, onBlockComplete, onMessageComplete). +- Observe thinking and text content streaming correctly from Anthropic models. + +## What is usable at the end + +| Capability | How to exercise it | +|---|---| +| Create an Anthropic provider | `AnthropicProvider.init(allocator, config)` | +| Run a chat via Anthropic | Same `agent.runStep()` call, different provider | +| Stream Anthropic responses | Same Receiver interface, same callback sequence | +| System prompts with Anthropic | System messages extracted and sent as top-level system field | + +## What is explicitly out of scope + +- Tools and tool-use (phase 3+) +- Extensions (phase 3+) +- Conversation serialization / disk persistence (phase 4) +- Server/proxy mode (future) +- Google API provider (future) + +--- + +## Receiver Interface + +The Receiver interface with the full 5-callback lifecycle is defined in phase 1. Both providers must produce the same callback sequence: + +- onMessageStart → onBlockStart → onContentDelta(s) → onBlockComplete → ... → onMessageComplete + +See `phase-1.md` for the full definition and contract. + +### How OpenAI synthesizes the callbacks + +Defined in phase 1. OpenAI has no explicit block boundaries; the provider infers them via a state machine that tracks the active block type and emits start/complete callbacks on transitions. + +### How Anthropic maps to the callbacks + +Anthropic's structured events map directly: + +| Anthropic event | Callback | +|---|---| +| `message_start` | onMessageStart(.assistant) | +| `content_block_start` | onBlockStart(type, index, meta if ToolUse) | +| `content_block_delta` | onContentDelta(index, delta bytes) | +| `content_block_stop` | onBlockComplete(index, assembled block) | +| `message_delta` + `message_stop` | onMessageComplete(assembled message) | + +No inference needed — Anthropic gives us explicit boundaries. + +--- + +## Anthropic Request Serialization + +### Wire format differences from OpenAI + +| Aspect | OpenAI | Anthropic | +|---|---|---| +| System prompt | Messages with `role: "system"` | Top-level `system` field (string) | +| Content shape | String or array of parts | Always array of content blocks | +| Tool results | Separate `role: "tool"` messages | Content blocks on `role: "user"` messages | +| Auth | `Authorization: Bearer <key>` | `x-api-key: <key>` + `anthropic-version` header | +| Streaming | `stream: true` in request body | `stream: true` in request body | + +### Serialization rules + +**System messages**: Extract all `role=.system` messages from the conversation. Concatenate their Text block contents into a single string. Set as the top-level `system` field. Do not include them in the messages array. + +**User messages**: Emit as `role: "user"`. Content blocks become Anthropic content block format: +- Text → `{ "type": "text", "text": "..." }` +- ToolResult → `{ "type": "tool_result", "tool_use_id": "...", "content": "..." }` (phase 3+) + +**Assistant messages**: Emit as `role: "assistant"`. Content blocks: +- Text → `{ "type": "text", "text": "..." }` +- Thinking → `{ "type": "thinking", "thinking": "..." }` +- ToolUse → `{ "type": "tool_use", "id": "...", "name": "...", "input": {...} }` (phase 3+) + +Note: Anthropic expects ToolUse's `input` as a parsed JSON object, not a string. Since we store `input` as raw bytes in a TextualBlock, we will need to parse it into a `std.json.Value` during Anthropic serialization. This is the one place where libpanto does parse tool input JSON — it's a serialization requirement, not an interpretation of the tool schema. The round-trip guarantee is: the bytes we stored serialize back to equivalent JSON when sent to Anthropic. + +--- + +## Anthropic Streaming Event Parser + +Each SSE event is a complete JSON object. The event type is in a top-level `type` field. + +### Event types and handling + +| Event type | What we extract | Action | +|---|---|---| +| `message_start` | `message.role`, `message.id`, `message.model` | Emit onMessageStart; begin assembling Message | +| `content_block_start` | `content_block.type`, `content_block.index`, `content_block.id`, `content_block.name` | Emit onBlockStart; create new TextualBlock or ToolUseBlock | +| `content_block_delta` | `delta.type` (text_delta, thinking_delta, input_json_delta), `delta.text` or `delta.thinking` or `delta.partial_json` | Append to current block's buffer; emit onContentDelta | +| `content_block_stop` | `index` | Emit onBlockComplete with assembled block | +| `message_delta` | `delta.stop_reason`, `usage` | Track stop reason for onMessageComplete | +| `message_stop` | (none) | Emit onMessageComplete with fully assembled Message | + +The parser is a separate concern from the SSE line parser (`sse.zig`). The SSE parser reassembles byte chunks into complete `data: {...}` events. The Anthropic event parser interprets the JSON of each event. They compose: `HTTP read → SSE parser → event strings → Anthropic event parser → callbacks`. + +--- + +## Module Changes + +### New files + +``` +src/provider_anthropic.zig // Anthropic provider implementation +``` + +### Modified files + +- `provider.zig` — ReceiverVTable expanded to the 5-callback lifecycle +- `provider_openai.zig` — No changes needed (block synthesis already built in phase 1) +- `agent.zig` — Any changes needed for expanded Receiver (should be minimal since Receiver is an interface) + +### Config + +``` +AnthropicConfig = struct { + api_key: []const u8, + base_url: []const u8, // e.g. "https://api.anthropic.com" + model: []const u8, // e.g. "claude-sonnet-4-20250514" + api_version: []const u8, // e.g. "2023-06-01" +}; +``` + +Separate from `OpenAIConfig`. The CLI and any higher-level config can unify them; libpanto treats them as distinct. + +--- + +## Testing Strategy + +### Unit tests + +| What | How | +|---|---| +| Anthropic serialization | Create conversations with system/user/assistant messages, serialize to Anthropic JSON, verify structure and system prompt extraction | +| Streaming event parser | Feed canned Anthropic SSE events (message_start, content_block_start, etc.) and verify correct callback sequence and assembled output | +| Block boundary synthesis | Feed OpenAI-style deltas (reasoning → content transitions) and verify onBlockStart/onBlockComplete emitted correctly | +| Receiver contract | Verify that both providers produce the same callback sequence for equivalent conversations | + +### Integration test (manual) + +- Run `panto` binary against Anthropic API with a real API key +- Hold a multi-turn conversation with thinking model +- Verify thinking and text stream correctly +- Switch to OpenAI provider, verify same experience |
