summaryrefslogtreecommitdiff
path: root/docs/phase-2.md
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-04-25 11:39:25 -0600
committerT <t@tjp.lol>2026-04-26 09:51:40 -0600
commitd3d5a6295a67bdc99e35e5bfd77359d98a850c3f (patch)
tree345f68ea32be6a3a63d7cab12953269ca74ad549 /docs/phase-2.md
initial documentation
Diffstat (limited to 'docs/phase-2.md')
-rw-r--r--docs/phase-2.md155
1 files changed, 155 insertions, 0 deletions
diff --git a/docs/phase-2.md b/docs/phase-2.md
new file mode 100644
index 0000000..877f8bd
--- /dev/null
+++ b/docs/phase-2.md
@@ -0,0 +1,155 @@
+# Phase 2: Anthropic Provider
+
+## 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 libawl 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; libawl 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 `awl` 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