From 576891dc2ec4d917932a4c396471d4bbbad90c8e Mon Sep 17 00:00:00 2001 From: T Date: Wed, 27 May 2026 08:04:04 -0600 Subject: 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 --- docs/phase-2.md | 157 -------------------------------------------------------- 1 file changed, 157 deletions(-) delete mode 100644 docs/phase-2.md (limited to 'docs/phase-2.md') diff --git a/docs/phase-2.md b/docs/phase-2.md deleted file mode 100644 index 4cb7ed9..0000000 --- a/docs/phase-2.md +++ /dev/null @@ -1,157 +0,0 @@ -# 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 ` | `x-api-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 -- cgit v1.3