diff --git a/Gemfile.lock b/Gemfile.lock index 8921be8..62d4480 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ PATH remote: . specs: ai-agents (0.9.1) - ruby_llm (~> 1.9.1) + ruby_llm (~> 1.13.2) GEM remote: https://rubygems.org/ @@ -20,15 +20,15 @@ GEM docile (1.4.1) erb (5.0.2) event_stream_parser (1.0.0) - faraday (2.14.0) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-multipart (1.1.1) + faraday-multipart (1.2.0) multipart-post (~> 2.0) faraday-net_http (3.4.2) net-http (~> 0.5) - faraday-retry (2.3.2) + faraday-retry (2.4.0) faraday (~> 2.0) google-protobuf (4.33.4) bigdecimal @@ -44,13 +44,13 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.17.1) + json (2.19.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) marcel (1.1.0) multipart-post (2.4.1) - net-http (0.8.0) + net-http (0.9.1) uri (>= 0.11.1) opentelemetry-api (1.7.0) opentelemetry-common (0.23.0) @@ -125,17 +125,17 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) ruby-progressbar (1.13.0) - ruby_llm (1.9.1) + ruby_llm (1.13.2) base64 event_stream_parser (~> 1) - faraday (>= 1.10.0) + faraday (>= 2.0) faraday-multipart (>= 1) faraday-net_http (>= 1) - faraday-retry (>= 1) - marcel (~> 1.0) - ruby_llm-schema (~> 0.2.1) + faraday-retry (>= 2.0) + marcel (~> 1) + ruby_llm-schema (~> 0) zeitwerk (~> 2) - ruby_llm-schema (0.2.1) + ruby_llm-schema (0.3.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -152,7 +152,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - zeitwerk (2.7.3) + zeitwerk (2.7.5) PLATFORMS arm64-darwin-24 diff --git a/ai-agents.gemspec b/ai-agents.gemspec index 581d946..616df10 100644 --- a/ai-agents.gemspec +++ b/ai-agents.gemspec @@ -31,7 +31,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Core dependencies - spec.add_dependency "ruby_llm", "~> 1.9.1" + spec.add_dependency "ruby_llm", "~> 1.13.2" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/lib/agents/runner.rb b/lib/agents/runner.rb index 5fceb81..af3737d 100644 --- a/lib/agents/runner.rb +++ b/lib/agents/runner.rb @@ -262,15 +262,12 @@ def restore_conversation_history(chat, context_wrapper) next end - message_params = build_message_params(msg) - next unless message_params # Skip invalid messages + message = build_restored_message(msg) + next unless message - message = RubyLLM::Message.new(**message_params) chat.add_message(message) - if message.role == :assistant && message_params[:tool_calls] - valid_tool_call_ids.merge(message_params[:tool_calls].keys) - end + valid_tool_call_ids.merge(message.tool_calls.keys) if message.role == :assistant && message.tool_calls end end @@ -287,18 +284,13 @@ def restorable_message?(msg) true end - # Build message parameters for restoration - def build_message_params(msg) + # Build a RubyLLM message from persisted history. + # RubyLLM 1.13.x now handles assistant tool-call messages with nil content, + # so we only normalize the stored shapes that still need adaptation: + # tool_calls arrays and multimodal attachment payloads. + def build_restored_message(msg) role = msg[:role].to_sym - - content_value = msg[:content] - # Assistant tool-call messages may have empty text, but still need placeholder content - content_value = "" if content_value.nil? && role == :assistant && msg[:tool_calls]&.any? - - params = { - role: role, - content: build_content(content_value) - } + params = { role: role, content: restored_message_content(msg[:content]) } # Handle tool-specific parameters (Tool Results) if role == :tool @@ -307,32 +299,25 @@ def build_message_params(msg) params[:tool_call_id] = msg[:tool_call_id] end - # FIX: Restore tool_calls on assistant messages - # This is required by OpenAI/Anthropic API contracts to link - # subsequent tool result messages back to this request. if role == :assistant && msg[:tool_calls] && !msg[:tool_calls].empty? - # Convert stored array of hashes back into the Hash format RubyLLM expects - # RubyLLM stores tool_calls as: { call_id => ToolCall_object, ... } - # Reference: openai/tools.rb:35 uses hash iteration |_, tc| - params[:tool_calls] = msg[:tool_calls].each_with_object({}) do |tc, hash| - tool_call_id = tc[:id] || tc["id"] - next unless tool_call_id - - hash[tool_call_id] = RubyLLM::ToolCall.new( - id: tool_call_id, - name: tc[:name] || tc["name"], - arguments: tc[:arguments] || tc["arguments"] || {} - ) - end + params[:tool_calls] = restored_tool_calls(msg[:tool_calls]) end - params + RubyLLM::Message.new(**params) end - # Build RubyLLM::Content from stored content, handling multimodal arrays with image attachments. - # Multimodal arrays follow the OpenAI content format: [{type: 'text', text: '...'}, {type: 'image_url', ...}] - def build_content(content_value) - return RubyLLM::Content.new(content_value) unless content_value.is_a?(Array) + def restored_message_content(content_value) + return build_multimodal_content(content_value) if content_value.is_a?(Array) + return RubyLLM::Content.new(content_value) if content_value.is_a?(Hash) + + content_value + end + + # Build RubyLLM::Content from stored multimodal arrays with image attachments. + # Persisted arrays follow the OpenAI content block format: + # [{type: 'text', text: '...'}, {type: 'image_url', image_url: {url: '...'}}] + def build_multimodal_content(content_value) + return content_value unless content_value.is_a?(Array) text_parts = content_value.filter_map { |p| p[:text] || p["text"] if (p[:type] || p["type"]) == "text" } image_urls = content_value.filter_map do |p| @@ -347,6 +332,19 @@ def build_content(content_value) image_urls.any? ? RubyLLM::Content.new(text, image_urls) : RubyLLM::Content.new(text) end + def restored_tool_calls(tool_calls) + tool_calls.each_with_object({}) do |tool_call, hash| + tool_call_id = tool_call[:id] || tool_call["id"] + next unless tool_call_id + + hash[tool_call_id] = RubyLLM::ToolCall.new( + id: tool_call_id, + name: tool_call[:name] || tool_call["name"], + arguments: tool_call[:arguments] || tool_call["arguments"] || {} + ) + end + end + # Validate tool message has required tool_call_id def valid_tool_message?(msg) if msg[:tool_call_id]