// This file implements the Anthropic Messages API streaming decoder (US-008). // It is a stateful Decoder (see transport.go) that translates Anthropic SSE // event payloads into the provider-agnostic AssistantMessageEvent set, // accumulating a partial AssistantMessage as deltas arrive. // // Anthropic streams a fixed event sequence: // // message_start → seeds id/model and initial usage (input tokens) // content_block_start → opens a text / thinking / tool_use block at an index // content_block_delta → text_delta / thinking_delta / signature_delta / // input_json_delta append to the open block // content_block_stop → closes the block (tool_use JSON is parsed here) // message_delta → carries the final stop_reason and output-token usage // message_stop → terminal; the accumulated message is emitted as done // error → a runtime error payload → terminal error event // // Per the dual failure model (FR-13) the decoder never panics: malformed // payloads and Anthropic `error` events are surfaced as a returned error (which // the transport turns into a terminal StreamErrorEvent) rather than crashing. package provider import ( "encoding/json" "fmt" "sort" "strings" "github.com/smallnest/pigo/internal/agentcore" ) // anthropicBlock accumulates one content block's streaming state, keyed by its // Anthropic content-block index. text/thinking append their deltas; tool_use // accumulates a partial JSON string parsed lazily when the block is realized. type anthropicBlock struct { kind string // "text" | "thinking" | "tool_use" | "redacted_thinking" text strings.Builder thinking strings.Builder thinkingSig string textSig string toolID string toolName string toolJSON strings.Builder redacted bool } // AnthropicDecoder is the stateful SSE decoder for the Anthropic Messages API. // It implements the transport Decoder interface. It is not safe for concurrent // use — the transport drives it from a single goroutine. type AnthropicDecoder struct { blocks map[int]*anthropicBlock order []int // content-block indices in first-seen order responseID string responseModel string inputTokens int outputTokens int stopReason string // mapped pigo stop reason (empty until message_delta) done bool // message_stop / done already emitted } // NewAnthropicDecoder builds a fresh decoder for one streamed response. func NewAnthropicDecoder() *AnthropicDecoder { return &AnthropicDecoder{blocks: make(map[int]*anthropicBlock)} } // anthropicEvent is the discriminated envelope shared by every Anthropic SSE // data payload; fields are populated selectively by event type. type anthropicEvent struct { Type string `json:"type"` Index int `json:"index"` Message *struct { ID string `json:"id"` Model string `json:"model"` Usage *anthropicUsage `json:"usage"` } `json:"message"` ContentBlock *struct { Type string `json:"type"` // text Text string `json:"text"` // thinking Thinking string `json:"thinking"` // tool_use ID string `json:"id"` Name string `json:"name"` } `json:"content_block"` Delta *struct { Type string `json:"type"` // text_delta Text string `json:"text"` // thinking_delta Thinking string `json:"thinking"` // signature_delta Signature string `json:"signature"` // input_json_delta PartialJSON string `json:"partial_json"` // message_delta StopReason string `json:"stop_reason"` } `json:"delta"` Usage *anthropicUsage `json:"usage"` Error *struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` } type anthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } // Decode turns one Anthropic SSE data payload into zero or more StreamEvents. func (d *AnthropicDecoder) Decode(payload []byte) ([]StreamEvent, error) { var ev anthropicEvent if err := json.Unmarshal(payload, &ev); err != nil { return nil, fmt.Errorf("anthropic: parse event: %w", err) } switch ev.Type { case "message_start": return d.onMessageStart(ev), nil case "content_block_start": return d.onBlockStart(ev), nil case "content_block_delta": return d.onBlockDelta(ev), nil case "content_block_stop": // Nothing to emit on stop; the block is already reflected in the partial. return nil, nil case "message_delta": return d.onMessageDelta(ev), nil case "message_stop": return d.finishDone(), nil case "ping": return nil, nil case "error": msg := "anthropic stream error" if ev.Error != nil { if ev.Error.Type != "" { msg = "anthropic " + ev.Error.Type } if ev.Error.Message != "" { msg += ": " + ev.Error.Message } } return nil, fmt.Errorf("%s", msg) default: // Unknown event types are ignored (forward-compatible). return nil, nil } } // Finish flushes a terminal done event if the stream ended without an explicit // message_stop (e.g. a clean EOF mid-stream), so a partial response is still // delivered rather than lost. func (d *AnthropicDecoder) Finish() ([]StreamEvent, error) { if d.done { return nil, nil } return d.finishDone(), nil } func (d *AnthropicDecoder) onMessageStart(ev anthropicEvent) []StreamEvent { if ev.Message != nil { d.responseID = ev.Message.ID d.responseModel = ev.Message.Model if ev.Message.Usage != nil { d.inputTokens = ev.Message.Usage.InputTokens d.outputTokens = ev.Message.Usage.OutputTokens } } return []StreamEvent{StreamStartEvent{Partial: d.partial()}} } func (d *AnthropicDecoder) onBlockStart(ev anthropicEvent) []StreamEvent { if ev.ContentBlock == nil { return nil } b := &anthropicBlock{kind: ev.ContentBlock.Type} switch ev.ContentBlock.Type { case "text": b.text.WriteString(ev.ContentBlock.Text) case "thinking": b.thinking.WriteString(ev.ContentBlock.Thinking) case "redacted_thinking": b.redacted = true case "tool_use": b.toolID = ev.ContentBlock.ID b.toolName = ev.ContentBlock.Name } d.putBlock(ev.Index, b) switch ev.ContentBlock.Type { case "thinking", "redacted_thinking": return []StreamEvent{StreamThinkingEvent{Partial: d.partial()}} case "tool_use": return []StreamEvent{StreamToolCallEvent{Partial: d.partial()}} default: return []StreamEvent{StreamTextEvent{Partial: d.partial()}} } } func (d *AnthropicDecoder) onBlockDelta(ev anthropicEvent) []StreamEvent { if ev.Delta == nil { return nil } b := d.blocks[ev.Index] if b == nil { // A delta for an unseen index: open a bare block so we don't drop data. b = &anthropicBlock{} d.putBlock(ev.Index, b) } switch ev.Delta.Type { case "text_delta": b.text.WriteString(ev.Delta.Text) return []StreamEvent{StreamTextEvent{Partial: d.partial()}} case "thinking_delta": b.thinking.WriteString(ev.Delta.Thinking) return []StreamEvent{StreamThinkingEvent{Partial: d.partial()}} case "signature_delta": // Signature belongs to the thinking block it rides on. b.thinkingSig += ev.Delta.Signature return []StreamEvent{StreamThinkingEvent{Partial: d.partial()}} case "input_json_delta": b.toolJSON.WriteString(ev.Delta.PartialJSON) return []StreamEvent{StreamToolCallEvent{Partial: d.partial()}} default: return nil } } func (d *AnthropicDecoder) onMessageDelta(ev anthropicEvent) []StreamEvent { if ev.Delta != nil && ev.Delta.StopReason != "" { d.stopReason = mapAnthropicStopReason(ev.Delta.StopReason) } if ev.Usage != nil { // message_delta reports cumulative output tokens (and sometimes input). if ev.Usage.OutputTokens != 0 { d.outputTokens = ev.Usage.OutputTokens } if ev.Usage.InputTokens != 0 { d.inputTokens = ev.Usage.InputTokens } } // No standalone event kind for usage/stop-reason accumulation; the values // surface in the terminal done message. return nil } // finishDone builds the terminal assistant message and marks the decoder done. func (d *AnthropicDecoder) finishDone() []StreamEvent { if d.done { return nil } d.done = true msg := d.partial() if msg.StopReason == "" { msg.StopReason = agentcore.StopReasonEndTurn } return []StreamEvent{StreamDoneEvent{Message: msg}} } // putBlock records a block at index, tracking first-seen order. func (d *AnthropicDecoder) putBlock(index int, b *anthropicBlock) { if _, seen := d.blocks[index]; !seen { d.order = append(d.order, index) } d.blocks[index] = b } // partial materializes the accumulated state into an AssistantMessage. Content // blocks are emitted in content-block index order. Tool-use JSON that has not // yet parsed cleanly is passed through as-is (raw partial), which is valid for // a still-streaming partial and finalized once the block completes. func (d *AnthropicDecoder) partial() agentcore.AssistantMessage { msg := agentcore.AssistantMessage{ RoleField: agentcore.RoleAssistant, API: "anthropic", Provider: "anthropic", StopReason: d.stopReason, ResponseID: d.responseID, ResponseModel: d.responseModel, } if d.inputTokens != 0 || d.outputTokens != 0 { msg.Usage = &agentcore.Usage{InputTokens: d.inputTokens, OutputTokens: d.outputTokens} } idx := make([]int, len(d.order)) copy(idx, d.order) sort.Ints(idx) for _, i := range idx { b := d.blocks[i] if b == nil { continue } switch b.kind { case "thinking", "redacted_thinking": tc := agentcore.NewThinkingContent(b.thinking.String()) tc.ThinkingSignature = b.thinkingSig tc.Redacted = b.redacted msg.Content = append(msg.Content, tc) case "tool_use": args := json.RawMessage(strings.TrimSpace(b.toolJSON.String())) if len(args) == 0 { args = json.RawMessage("{}") } msg.Content = append(msg.Content, agentcore.NewToolCallContent(b.toolID, b.toolName, args)) default: // text tc := agentcore.NewTextContent(b.text.String()) tc.TextSignature = b.textSig msg.Content = append(msg.Content, tc) } } return msg } // mapAnthropicStopReason maps an Anthropic stop_reason to the pigo StopReason // set. Unknown reasons default to end_turn (a natural, non-error stop). func mapAnthropicStopReason(reason string) string { switch reason { case "max_tokens": return agentcore.StopReasonLength case "tool_use": return agentcore.StopReasonToolUse case "end_turn", "stop_sequence": return agentcore.StopReasonEndTurn default: return agentcore.StopReasonEndTurn } }