# frozen_string_literal: true module Dispatch module Adapter class Claude < Base module RequestBuilder # Converts an Array into the Anthropic # messages wire format: [{role:, content:}, …]. # # Key transformations applied here: # - Adjacent "tool result" messages are merged into one user message. # - Thinking block signature rules are applied on assistant messages. # - Image blocks are dropped when the model doesn't accept images. # - Empty text/thinking blocks are elided. # - A trailing assistant message gets a synthetic "Continue." user turn. # - Tool names in tool_use blocks are prefixed with "proxy_" for OAuth. # # A message is treated as a "tool result message" when its content is an # Array exclusively containing ToolResultBlock objects. module Messages module_function # Convert messages to Anthropic wire format. # # @param messages [Array] # @param model_info [Dispatch::Adapter::ModelInfo, nil] # @param is_oauth [Boolean] # @return [Array] def build(messages, model_info: nil, is_oauth: false) params = [] i = 0 while i < messages.length msg = messages[i] # ── Tool result batching ────────────────────────────────────────── if tool_result_message?(msg) tool_blocks = [] while i < messages.length && tool_result_message?(messages[i]) tool_blocks.concat(extract_tool_result_blocks(messages[i])) i += 1 end params << { role: "user", content: tool_blocks } next end # ── Normal message roles ────────────────────────────────────────── case msg.role.to_s when "user", "developer" wire = convert_user_message(msg, model_info: model_info) params << wire if wire when "assistant" wire = convert_assistant_message(msg, is_oauth: is_oauth) params << wire if wire end # Any unrecognised roles (e.g. "system") are silently skipped; # system prompts are handled separately by the Cloaking module. i += 1 end # If the last emitted message is assistant, the API requires a user # follow-up (otherwise it returns an error). Use the same synthetic # "Continue." that oh-my-pi uses. params << { role: "user", content: "Continue." } if params.last&.dig(:role) == "assistant" params end # ── Tool result helpers ───────────────────────────────────────────── # Returns true when the message's content is exclusively # ToolResultBlock objects (the canonical Ruby wire shape for tool # results). def tool_result_message?(msg) content = msg.content return false unless content.is_a?(Array) return false if content.empty? content.all?(ToolResultBlock) end # Convert all ToolResultBlock objects in a message to Anthropic # tool_result content block hashes. def extract_tool_result_blocks(msg) msg.content.map do |block| wire = { type: "tool_result", tool_use_id: block.tool_use_id, is_error: block.is_error } converted = convert_tool_result_content(block.content) wire[:content] = converted unless converted.nil? wire end end # Convert the inner content of a ToolResultBlock to the Anthropic # wire shape (String or Array<{type:text|image, …}>). def convert_tool_result_content(content) case content when String content.empty? ? nil : content when Array blocks = content.flat_map { |b| convert_content_for_tool_result(b) }.compact blocks.empty? ? nil : blocks when nil nil else content.to_s.then { |s| s.empty? ? nil : s } end end def convert_content_for_tool_result(block) case block when TextBlock text = block.text.to_s text.empty? ? [] : [{ type: "text", text: text }] when ImageBlock [build_image_block(block)] else [] end end # ── User / developer messages ──────────────────────────────────────── def convert_user_message(msg, model_info:) case msg.content when String return nil if msg.content.strip.empty? { role: "user", content: msg.content } when Array blocks = msg.content.flat_map { |b| convert_user_block(b) }.compact # Strip image blocks when the model does not support vision blocks = blocks.reject { |b| b[:type] == "image" } unless vision_supported?(model_info) # Drop empty text blocks blocks.reject! { |b| b[:type] == "text" && b[:text].to_s.strip.empty? } return nil if blocks.empty? { role: "user", content: blocks } end end def convert_user_block(block) case block when TextBlock text = block.text.to_s text.empty? ? [] : [{ type: "text", text: text }] when ImageBlock [build_image_block(block)] when ToolResultBlock # ToolResultBlock objects within a user message array are handled # inline (single message that is already batched). This path is a # safety net for mixed-content messages — they are passed through # as tool_result blocks inside the user message. wire = { type: "tool_result", tool_use_id: block.tool_use_id, is_error: block.is_error } converted = convert_tool_result_content(block.content) wire[:content] = converted unless converted.nil? [wire] else [] end end def build_image_block(block) { type: "image", source: { type: "base64", media_type: block.media_type, data: block.source } } end def vision_supported?(model_info) return true if model_info.nil? model_info.supports_vision end # ── Assistant messages ─────────────────────────────────────────────── def convert_assistant_message(msg, is_oauth:) content = msg.content return nil unless content.is_a?(Array) # Determine signature policy for thinking blocks in this message. # If ANY sibling thinking block is signed, we are in "signed context": # - signed blocks → {type:"thinking", thinking:, signature:} # - unsigned blocks → downgraded to plain text # Otherwise (no signed siblings): # - signed blocks → {type:"thinking", thinking:, signature:} # - unsigned blocks → plain text has_signed_thinking = content.any? do |b| b.is_a?(ThinkingBlock) && !b.signature.to_s.strip.empty? end blocks = content.flat_map do |block| convert_assistant_block(block, has_signed_thinking: has_signed_thinking, is_oauth: is_oauth) end.compact return nil if blocks.empty? { role: "assistant", content: blocks } end def convert_assistant_block(block, has_signed_thinking:, is_oauth:) case block when TextBlock return [] if block.text.to_s.strip.empty? [{ type: "text", text: block.text }] when ThinkingBlock convert_thinking_block(block, has_signed_thinking: has_signed_thinking) when RedactedThinkingBlock return [] if block.data.to_s.strip.empty? [{ type: "redacted_thinking", data: block.data }] when ToolUseBlock name = is_oauth ? Cloaking.apply_prefix(block.name) : block.name [{ type: "tool_use", id: block.id, name: name, input: block.arguments || {} }] else [] end end # Apply the thinking-block signature rules described in research §3.1. # # Signed context (has_signed_thinking = true): # - Block has non-empty signature → pass through as "thinking" # - Block has no / empty signature → downgrade to plain text # (drop if the thinking text is also empty) # # Unsigned context (has_signed_thinking = false): # - Block has non-empty signature → pass through as "thinking" # - Block has no signature → plain text (drop if empty) def convert_thinking_block(block, has_signed_thinking: false) # rubocop:disable Lint/UnusedMethodArgument signed = !block.signature.to_s.strip.empty? if signed [{ type: "thinking", thinking: block.thinking, signature: block.signature }] else # Unsigned block: downgrade to text or drop (both signed-context # and unsigned-context cases produce identical output). return [] if block.thinking.to_s.strip.empty? [{ type: "text", text: block.thinking }] end end end end end end end