| 1 | package anthropic |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | |
| 6 | "reasonix/internal/provider" |
| 7 | ) |
| 8 | |
| 9 | type messageAssembler struct { |
| 10 | client *client |
| 11 | messages []anthMessage |
| 12 | pendingImages []contentBlock |
| 13 | } |
| 14 | |
| 15 | func (b *messageAssembler) appendBlocks(role string, blocks ...contentBlock) { |
| 16 | if len(blocks) == 0 { |
| 17 | return |
| 18 | } |
| 19 | if n := len(b.messages); n > 0 && b.messages[n-1].Role == role { |
| 20 | b.messages[n-1].Content = mergeThinkingFirst(b.messages[n-1].Content, blocks) |
| 21 | return |
| 22 | } |
| 23 | b.messages = append(b.messages, anthMessage{Role: role, Content: blocks}) |
| 24 | } |
| 25 | func (b *messageAssembler) flushImages() { |
| 26 | b.appendBlocks("user", b.pendingImages...) |
| 27 | b.pendingImages = nil |
| 28 | } |
| 29 | func (c *client) buildMessages(messages []provider.Message) ([]textBlock, []anthMessage) { |
| 30 | var system []textBlock |
| 31 | b := messageAssembler{client: c} |
| 32 | for _, m := range provider.SanitizeToolPairing(c.replayMessages(messages)) { |
| 33 | if m.Role != provider.RoleTool { |
| 34 | b.flushImages() |
| 35 | } |
| 36 | switch m.Role { |
| 37 | case provider.RoleSystem: |
| 38 | if m.Content != "" { |
| 39 | system = append(system, textBlock{Type: "text", Text: m.Content}) |
| 40 | } |
| 41 | case provider.RoleUser: |
| 42 | b.addUser(m) |
| 43 | case provider.RoleTool: |
| 44 | b.addTool(m) |
| 45 | case provider.RoleAssistant: |
| 46 | b.addAssistant(m) |
| 47 | } |
| 48 | } |
| 49 | b.flushImages() |
| 50 | return system, b.messages |
| 51 | } |
| 52 | |
| 53 | func (b *messageAssembler) addUser(m provider.Message) { |
| 54 | if m.Content != "" { |
| 55 | b.appendBlocks("user", sessionContextTextBlocks(m.Content)...) |
| 56 | } |
| 57 | if b.client.vision { |
| 58 | for _, ref := range m.Images { |
| 59 | if src := imageSourceFromRef(ref); src != nil { |
| 60 | b.appendBlocks("user", contentBlock{Type: "image", Source: src}) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func (b *messageAssembler) addTool(m provider.Message) { |
| 67 | content := m.Content |
| 68 | if content == "" { |
| 69 | content = "(no output)" // tool_result content must be non-empty |
| 70 | } |
| 71 | block := contentBlock{Type: "tool_result", ToolUseID: m.ToolCallID, Content: content} |
| 72 | if b.client.vision && !b.client.deepseek { |
| 73 | if blocks := toolResultBlocks(content, m.Images); blocks != nil { |
| 74 | block.Content = blocks |
| 75 | } |
| 76 | } |
| 77 | b.appendBlocks("user", block) |
| 78 | if b.client.vision && b.client.deepseek { |
| 79 | for _, ref := range m.Images { |
| 80 | if src := imageSourceFromRef(ref); src != nil { |
| 81 | b.pendingImages = append(b.pendingImages, contentBlock{Type: "image", Source: src}) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func (b *messageAssembler) addAssistant(m provider.Message) { |
| 88 | var blocks []contentBlock |
| 89 | // Replay reasoning before content: DeepSeek needs historical thinking; |
| 90 | // Anthropic requires a valid signature. replayReasoningBlocks owns both rules. |
| 91 | blocks = append(blocks, b.client.replayReasoningBlocks(m)...) |
| 92 | blocks = appendServerSearchBlocks(blocks, m.ServerSearch) |
| 93 | if m.Content != "" { |
| 94 | blocks = append(blocks, contentBlock{Type: "text", Text: m.Content}) |
| 95 | } |
| 96 | for _, tc := range m.ToolCalls { |
| 97 | input := json.RawMessage(tc.Arguments) |
| 98 | if len(input) == 0 { |
| 99 | input = json.RawMessage("{}") // input is required, even when empty |
| 100 | } |
| 101 | blocks = append(blocks, contentBlock{Type: "tool_use", ID: tc.ID, Name: tc.Name, Input: input}) |
| 102 | } |
| 103 | b.appendBlocks("assistant", blocks...) |
| 104 | } |
| 105 |