Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- **Tool Message Restoration Bug**: Fixed bug where tool messages were not restored from conversation history

## [0.7.0] - 2025-10-16

### Added
Expand Down
32 changes: 21 additions & 11 deletions lib/agents/helpers/message_extractor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,29 +54,39 @@ def extract_messages(chat, current_agent)
end

def extract_user_or_assistant_message(msg, current_agent)
return nil unless msg.content && !content_empty?(msg.content)
content_present = message_content?(msg)
tool_calls_present = assistant_tool_calls?(msg)
return nil unless content_present || tool_calls_present

message = {
role: msg.role,
content: msg.content
content: content_present ? msg.content : ""
}

if msg.role == :assistant
# Add agent attribution for conversation continuity
message[:agent_name] = current_agent.name if current_agent
return message unless msg.role == :assistant

# Add tool calls if present
if msg.tool_call? && msg.tool_calls
# RubyLLM stores tool_calls as Hash with call_id => ToolCall object
# Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
message[:tool_calls] = msg.tool_calls.values.map(&:to_h)
end
message[:agent_name] = current_agent.name if current_agent

if tool_calls_present
# RubyLLM stores tool_calls as Hash with call_id => ToolCall object
# Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
message[:tool_calls] = msg.tool_calls.values.map(&:to_h)
end

message
end
private_class_method :extract_user_or_assistant_message

def message_content?(msg)
msg.content && !content_empty?(msg.content)
end
private_class_method :message_content?

def assistant_tool_calls?(msg)
msg.role == :assistant && msg.tool_call? && msg.tool_calls && !msg.tool_calls.empty?
end
private_class_method :assistant_tool_calls?

def extract_tool_message(msg)
return nil unless msg.tool_result?

Expand Down
89 changes: 79 additions & 10 deletions lib/agents/runner.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# frozen_string_literal: true

require "ostruct"
require "set"

module Agents
# The execution engine that orchestrates conversations between users and agents.
# Runner manages the conversation flow, handles tool execution through RubyLLM,
Expand Down Expand Up @@ -264,26 +267,92 @@ def deep_copy_context(context)

# Restores conversation history from context into RubyLLM chat.
# Converts stored message hashes back into RubyLLM::Message objects with proper content handling.
# Supports user, assistant, and tool role messages for complete conversation continuity.
#
# @param chat [RubyLLM::Chat] The chat instance to restore history into
# @param context_wrapper [RunContext] Context containing conversation history
def restore_conversation_history(chat, context_wrapper)
history = context_wrapper.context[:conversation_history] || []
valid_tool_call_ids = Set.new

history.each do |msg|
# Only restore user and assistant messages with content
next unless %i[user assistant].include?(msg[:role].to_sym)
next unless msg[:content] && !Helpers::MessageExtractor.content_empty?(msg[:content])
next unless restorable_message?(msg)

# Extract text content safely - handle both string and hash content
content = RubyLLM::Content.new(msg[:content])
if msg[:role].to_sym == :tool &&
msg[:tool_call_id] &&
!valid_tool_call_ids.include?(msg[:tool_call_id])
Agents.logger&.warn("Skipping tool message without matching assistant tool_call_id #{msg[:tool_call_id]}")
next
end

# Create a proper RubyLLM::Message and pass it to add_message
message = RubyLLM::Message.new(
role: msg[:role].to_sym,
content: content
)
message_params = build_message_params(msg)
next unless message_params # Skip invalid messages

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
end
end

# Check if a message should be restored
def restorable_message?(msg)
role = msg[:role].to_sym
return false unless %i[user assistant tool].include?(role)

# Allow assistant messages that only contain tool calls (no text content)
tool_calls_present = role == :assistant && msg[:tool_calls] && !msg[:tool_calls].empty?
return false if role != :tool && !tool_calls_present &&
Helpers::MessageExtractor.content_empty?(msg[:content])

true
end

# Build message parameters for restoration
def build_message_params(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: RubyLLM::Content.new(content_value)
}

# Handle tool-specific parameters (Tool Results)
if role == :tool
return nil unless valid_tool_message?(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"]
hash[tool_call_id] = OpenStruct.new(tc)
end
end

params
end

# Validate tool message has required tool_call_id
def valid_tool_message?(msg)
if msg[:tool_call_id]
true
else
Agents.logger&.warn("Skipping tool message without tool_call_id in conversation history")
false
end
end

Expand Down
40 changes: 40 additions & 0 deletions spec/agents/helpers/message_extractor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,46 @@
])
end
end

context "when assistant tool calls have no text content" do
let(:tool_call) do
instance_double(RubyLLM::ToolCall,
to_h: {
id: "call_456",
name: "test_tool",
arguments: { foo: "bar" }
})
end

let(:assistant_with_tool_only) do
instance_double(RubyLLM::Message,
role: :assistant,
content: nil,
tool_call?: true,
tool_calls: { "call_456" => tool_call })
end

let(:chat) { instance_double(RubyLLM::Chat, messages: [assistant_with_tool_only]) }

it "preserves the message and tool calls with empty string content" do
result = described_class.extract_messages(chat, current_agent)

expect(result).to eq([
{
role: :assistant,
content: "",
agent_name: "TestAgent",
tool_calls: [
{
id: "call_456",
name: "test_tool",
arguments: { foo: "bar" }
}
]
}
])
end
end
end

describe ".content_empty?" do
Expand Down
Loading
Loading