ruby-pi 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d5744eb38aa0fbf2ff5230ae86a66c85c3ed6c2bdc50fc9bcdd1f0514752fe9
4
- data.tar.gz: 8deaa07b7bfe6157de4859ddfb71c68508ef4bc7c3b6ad753bf1a64d44ef597e
3
+ metadata.gz: 55e144c9f54733981c81f090dad3eb02a4f6cbac0dd4d2a220d972c04133ea97
4
+ data.tar.gz: abdbb72cfafd293dc30f4517c42dd02f147a41e501034efb7eee2b8af20f9577
5
5
  SHA512:
6
- metadata.gz: c0c325552e6f82dddcd85c735ce9faa6aa21a8c4424af6b411bae90779a28e32aaecf05c119094d9befd5715e64f16654e435b037561c9446413c2e01c89d898
7
- data.tar.gz: cf11a6b47b18beb2c8b35caedb036e44a5a3ccf3c62e96cc5e37209bab237e4c16cb9f05784bfc4d0ce804433964c802fa0699362772adbf1926520717233a78
6
+ metadata.gz: 608d20395e47a22f7392150e131e5944bab03aabbcc089525c0fcc43f966e3672f3f2cb66e74d34657acdc108b939cbe80fa6b8ba90b281717fb0e1c43cc7242
7
+ data.tar.gz: d5d0c2f2e1a24928a1b9ee998781f43d1f0d1a5d76aebbcd24d3ed42c11ae132c0c1df10996cbe23676fc94c047d12e456eea5279406484676ddcc3ce3b63068
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] - 2026-04-29
9
+
10
+ ### Fixed
11
+
12
+ - Anthropic provider: tool result messages (`role: "tool"`) are now converted to `role: "user"` with `tool_result` content blocks containing the matching `tool_use_id`, and consecutive tool results from the same turn are grouped into a single user message as the Messages API requires
13
+ - Anthropic provider: assistant messages with `tool_calls` are now preserved as `tool_use` content blocks (`{type: "tool_use", id:, name:, input:}`) instead of being silently discarded, so the API can match them to subsequent tool results
14
+ - Anthropic and OpenAI providers: structured `content` (Arrays/Hashes, e.g. multimodal vision payloads) is preserved as-is instead of being collapsed by `to_s`
15
+ - OpenAI provider: tool messages now include `tool_call_id` and assistant `tool_calls` are emitted in the full OpenAI function-call structure for proper result matching
16
+
17
+ ### Added
18
+
19
+ - Comprehensive unit tests for `build_request_body` in both Anthropic and OpenAI providers covering full agent-loop conversations, consecutive tool result grouping, structured content preservation, and string- vs symbol-keyed message edge cases
20
+
8
21
  ## [0.1.1] - 2026-04-28
9
22
 
10
23
  ### Changed
@@ -78,12 +78,29 @@ module RubyPi
78
78
 
79
79
  # Builds the Anthropic API request body from messages and tools.
80
80
  #
81
+ # Handles three critical conversions from the internal message format to
82
+ # Anthropic's API format:
83
+ #
84
+ # 1. System messages (role: "system") are extracted and promoted to the
85
+ # top-level `system:` parameter, since Anthropic does not allow system
86
+ # messages in the messages array.
87
+ #
88
+ # 2. Tool result messages (role: "tool") are converted to role: "user"
89
+ # messages with `tool_result` content blocks. Consecutive tool messages
90
+ # are grouped into a single user message, as Anthropic requires.
91
+ #
92
+ # 3. Assistant messages that include `tool_calls` are converted to include
93
+ # `tool_use` content blocks, so the API can match them to subsequent
94
+ # `tool_result` blocks.
95
+ #
96
+ # Structured content (Arrays, Hashes) is preserved as-is and never
97
+ # coerced via `.to_s`, which would destroy the content block structure.
98
+ #
81
99
  # @param messages [Array<Hash>] conversation messages
82
100
  # @param tools [Array<Hash>] tool definitions
83
101
  # @param stream [Boolean] whether streaming is enabled
84
102
  # @return [Hash] the request body
85
103
  def build_request_body(messages, tools, stream)
86
- # Separate system message from conversation messages
87
104
  system_message = nil
88
105
  conversation = []
89
106
 
@@ -91,10 +108,41 @@ module RubyPi
91
108
  role = (msg[:role] || msg["role"]).to_s
92
109
  content = msg[:content] || msg["content"]
93
110
 
94
- if role == "system"
111
+ case role
112
+ when "system"
113
+ # Anthropic requires system prompts as a top-level parameter, not
114
+ # as a message in the conversation array.
95
115
  system_message = content.to_s
116
+
117
+ when "tool"
118
+ # Internal tool-result messages must be converted to Anthropic's
119
+ # format: role "user" with a tool_result content block. The
120
+ # tool_use_id links this result back to the assistant's tool_use.
121
+ tool_result_block = build_tool_result_block(msg)
122
+
123
+ # Group consecutive tool results into a single "user" message.
124
+ # Anthropic requires this because alternating user/assistant roles
125
+ # means multiple tool results from one turn must share one user msg.
126
+ if conversation.last && conversation.last[:role] == "user" &&
127
+ conversation.last[:content].is_a?(Array) &&
128
+ conversation.last[:content].all? { |b| b[:type] == "tool_result" }
129
+ # Append to the existing grouped tool_result user message
130
+ conversation.last[:content] << tool_result_block
131
+ else
132
+ conversation << { role: "user", content: [tool_result_block] }
133
+ end
134
+
135
+ when "assistant"
136
+ # Build the assistant message with proper content blocks.
137
+ # If the message contains tool_calls, they must be included as
138
+ # tool_use content blocks so Anthropic can match them to the
139
+ # subsequent tool_result blocks.
140
+ conversation << build_assistant_message(msg, content)
141
+
96
142
  else
97
- conversation << { role: role, content: content.to_s }
143
+ # Standard user (or other) messages — preserve structured content
144
+ # as-is and only convert simple values to strings.
145
+ conversation << { role: role, content: format_content(content) }
98
146
  end
99
147
  end
100
148
 
@@ -114,6 +162,118 @@ module RubyPi
114
162
  body
115
163
  end
116
164
 
165
+ # Builds an Anthropic tool_result content block from an internal tool
166
+ # message. Extracts the tool_call_id and content, handling edge cases
167
+ # like nil IDs or already-structured content.
168
+ #
169
+ # @param msg [Hash] internal tool message with :tool_call_id, :content, :name
170
+ # @return [Hash] Anthropic tool_result content block
171
+ def build_tool_result_block(msg)
172
+ tool_use_id = msg[:tool_call_id] || msg["tool_call_id"]
173
+ content = msg[:content] || msg["content"]
174
+
175
+ block = {
176
+ type: "tool_result",
177
+ tool_use_id: tool_use_id || "unknown"
178
+ }
179
+
180
+ # Content can be a simple string or a structured content array.
181
+ # Preserve structured content as-is; convert simple values to strings.
182
+ if content.is_a?(Array)
183
+ block[:content] = content
184
+ elsif content.is_a?(Hash)
185
+ block[:content] = [content]
186
+ elsif content.nil?
187
+ block[:content] = ""
188
+ else
189
+ block[:content] = content.to_s
190
+ end
191
+
192
+ block
193
+ end
194
+
195
+ # Builds an Anthropic-formatted assistant message, including tool_use
196
+ # content blocks when the message has tool_calls.
197
+ #
198
+ # Anthropic represents assistant responses as an array of content blocks.
199
+ # Text content becomes `{ type: "text", text: "..." }` blocks, and tool
200
+ # calls become `{ type: "tool_use", id: "...", name: "...", input: {...} }`
201
+ # blocks. Both can appear in the same message.
202
+ #
203
+ # @param msg [Hash] internal assistant message with optional :tool_calls
204
+ # @param content [String, Array, Hash, nil] the message content
205
+ # @return [Hash] Anthropic-formatted assistant message
206
+ def build_assistant_message(msg, content)
207
+ tool_calls = msg[:tool_calls] || msg["tool_calls"]
208
+ content_blocks = []
209
+
210
+ # Add text content block if present. Content may already be a structured
211
+ # array (from a previous Anthropic response) — preserve it as-is.
212
+ if content.is_a?(Array)
213
+ content_blocks.concat(content)
214
+ elsif content.is_a?(Hash)
215
+ content_blocks << content
216
+ elsif content && !content.to_s.empty?
217
+ content_blocks << { type: "text", text: content.to_s }
218
+ end
219
+
220
+ # Convert internal tool_calls into Anthropic tool_use content blocks.
221
+ # Each tool_call has :id, :name, and :arguments from ToolCall#to_h.
222
+ if tool_calls.is_a?(Array) && !tool_calls.empty?
223
+ tool_calls.each do |tc|
224
+ tc_id = tc[:id] || tc["id"]
225
+ tc_name = tc[:name] || tc["name"]
226
+ tc_args = tc[:arguments] || tc["arguments"] || {}
227
+
228
+ # Ensure arguments is a Hash; parse JSON string if needed
229
+ tc_input = if tc_args.is_a?(Hash)
230
+ tc_args
231
+ elsif tc_args.is_a?(String) && !tc_args.empty?
232
+ begin
233
+ JSON.parse(tc_args)
234
+ rescue JSON::ParserError
235
+ { "_raw" => tc_args }
236
+ end
237
+ else
238
+ {}
239
+ end
240
+
241
+ content_blocks << {
242
+ type: "tool_use",
243
+ id: tc_id || "unknown",
244
+ name: tc_name || "unknown",
245
+ input: tc_input
246
+ }
247
+ end
248
+ end
249
+
250
+ # If no content blocks were generated (edge case), add an empty text
251
+ # block to satisfy Anthropic's requirement for non-empty content.
252
+ content_blocks << { type: "text", text: "" } if content_blocks.empty?
253
+
254
+ { role: "assistant", content: content_blocks }
255
+ end
256
+
257
+ # Formats message content for the Anthropic API, preserving structured
258
+ # content (Arrays and Hashes) and only converting simple values to strings.
259
+ #
260
+ # Anthropic accepts both a plain string and an array of content blocks
261
+ # for the `content` field. Calling `.to_s` on structured content would
262
+ # destroy it, so this method passes Arrays and Hashes through unchanged.
263
+ #
264
+ # @param content [String, Array, Hash, nil] the raw content value
265
+ # @return [String, Array, Hash] formatted content suitable for Anthropic
266
+ def format_content(content)
267
+ case content
268
+ when Array, Hash
269
+ content
270
+ when nil
271
+ ""
272
+ else
273
+ content.to_s
274
+ end
275
+ end
276
+
117
277
  # Converts a tool definition to Anthropic's tool format.
118
278
  # Accepts either a RubyPi::Tools::Definition or a plain Hash.
119
279
  #
@@ -70,6 +70,20 @@ module RubyPi
70
70
 
71
71
  # Builds the OpenAI Chat Completions request body.
72
72
  #
73
+ # Handles proper formatting of all message types:
74
+ #
75
+ # 1. System messages pass through with role "system" (OpenAI supports them).
76
+ #
77
+ # 2. Tool result messages (role: "tool") are formatted with the required
78
+ # `tool_call_id` field so OpenAI can match them to the assistant's
79
+ # tool_calls.
80
+ #
81
+ # 3. Assistant messages with `tool_calls` include the proper OpenAI
82
+ # tool_calls structure: `{ id, type: "function", function: { name, arguments } }`.
83
+ #
84
+ # Structured content (Arrays, Hashes) is preserved for multimodal content
85
+ # blocks (e.g., vision messages with image_url content parts).
86
+ #
73
87
  # @param messages [Array<Hash>] conversation messages
74
88
  # @param tools [Array<Hash>] tool definitions
75
89
  # @param stream [Boolean] whether streaming is enabled
@@ -91,13 +105,101 @@ module RubyPi
91
105
 
92
106
  # Converts a normalized message hash to OpenAI's message format.
93
107
  #
108
+ # Handles three message types:
109
+ # - Tool messages (role: "tool"): includes tool_call_id for result matching
110
+ # - Assistant messages with tool_calls: includes structured tool_calls array
111
+ # - Standard messages: role + content with structured content preservation
112
+ #
94
113
  # @param message [Hash] a message with :role and :content keys
95
114
  # @return [Hash] OpenAI-formatted message
96
115
  def format_message(message)
97
- {
98
- role: (message[:role] || message["role"]).to_s,
99
- content: (message[:content] || message["content"]).to_s
116
+ role = (message[:role] || message["role"]).to_s
117
+ content = message[:content] || message["content"]
118
+
119
+ case role
120
+ when "tool"
121
+ # OpenAI accepts role "tool" with a required tool_call_id field
122
+ # to match this result back to the assistant's tool_call.
123
+ tool_call_id = message[:tool_call_id] || message["tool_call_id"]
124
+ {
125
+ role: "tool",
126
+ tool_call_id: tool_call_id || "unknown",
127
+ content: format_content(content)
128
+ }
129
+
130
+ when "assistant"
131
+ # Assistant messages may include tool_calls that must be preserved
132
+ # in OpenAI's expected format for conversation continuity.
133
+ build_assistant_message(message, content)
134
+
135
+ else
136
+ # System and user messages — preserve structured content as-is
137
+ # for multimodal support (vision, etc.).
138
+ { role: role, content: format_content(content) }
139
+ end
140
+ end
141
+
142
+ # Builds an OpenAI-formatted assistant message, including tool_calls
143
+ # when present. OpenAI requires tool_calls in a specific structure:
144
+ # `{ id, type: "function", function: { name, arguments } }` where
145
+ # arguments is a JSON string.
146
+ #
147
+ # @param message [Hash] internal assistant message with optional :tool_calls
148
+ # @param content [String, Array, Hash, nil] the message content
149
+ # @return [Hash] OpenAI-formatted assistant message
150
+ def build_assistant_message(message, content)
151
+ tool_calls = message[:tool_calls] || message["tool_calls"]
152
+
153
+ formatted = {
154
+ role: "assistant",
155
+ content: format_content(content)
100
156
  }
157
+
158
+ # Include tool_calls in OpenAI's expected format if present
159
+ if tool_calls.is_a?(Array) && !tool_calls.empty?
160
+ formatted[:tool_calls] = tool_calls.map do |tc|
161
+ tc_id = tc[:id] || tc["id"]
162
+ tc_name = tc[:name] || tc["name"]
163
+ tc_args = tc[:arguments] || tc["arguments"] || {}
164
+
165
+ # OpenAI requires arguments as a JSON string
166
+ args_string = if tc_args.is_a?(String)
167
+ tc_args
168
+ elsif tc_args.is_a?(Hash)
169
+ JSON.generate(tc_args)
170
+ else
171
+ "{}"
172
+ end
173
+
174
+ {
175
+ id: tc_id || "unknown",
176
+ type: "function",
177
+ function: {
178
+ name: tc_name || "unknown",
179
+ arguments: args_string
180
+ }
181
+ }
182
+ end
183
+ end
184
+
185
+ formatted
186
+ end
187
+
188
+ # Formats message content for the OpenAI API, preserving structured
189
+ # content (Arrays and Hashes) for multimodal messages (e.g., vision)
190
+ # and only converting simple values to strings.
191
+ #
192
+ # @param content [String, Array, Hash, nil] the raw content value
193
+ # @return [String, Array, Hash, nil] formatted content suitable for OpenAI
194
+ def format_content(content)
195
+ case content
196
+ when Array, Hash
197
+ content
198
+ when nil
199
+ nil
200
+ else
201
+ content.to_s
202
+ end
101
203
  end
102
204
 
103
205
  # Converts a tool definition to OpenAI's function tool format.
@@ -7,5 +7,5 @@
7
7
 
8
8
  module RubyPi
9
9
  # The current version of the RubyPi gem, following Semantic Versioning.
10
- VERSION = "0.1.1"
10
+ VERSION = "0.1.2"
11
11
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-pi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - RubyPi Contributors