Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ai-agents.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 36 additions & 38 deletions lib/agents/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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|
Expand All @@ -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]
Expand Down
Loading