Skip to content

Commit 596f49f

Browse files
committed
Handle tool-call-only assistant history messages and guard orphaned tool results
1 parent 1ceea21 commit 596f49f

File tree

4 files changed

+102
-5
lines changed

4 files changed

+102
-5
lines changed

lib/agents/helpers/message_extractor.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,21 @@ def extract_messages(chat, current_agent)
5454
end
5555

5656
def extract_user_or_assistant_message(msg, current_agent)
57-
return nil unless msg.content && !content_empty?(msg.content)
57+
content_present = msg.content && !content_empty?(msg.content)
58+
has_tool_calls = msg.role == :assistant && msg.tool_call? && msg.tool_calls && !msg.tool_calls.empty?
59+
return nil unless content_present || has_tool_calls
5860

5961
message = {
6062
role: msg.role,
61-
content: msg.content
63+
content: content_present ? msg.content : ""
6264
}
6365

6466
if msg.role == :assistant
6567
# Add agent attribution for conversation continuity
6668
message[:agent_name] = current_agent.name if current_agent
6769

6870
# Add tool calls if present
69-
if msg.tool_call? && msg.tool_calls
71+
if has_tool_calls
7072
# RubyLLM stores tool_calls as Hash with call_id => ToolCall object
7173
# Reference: RubyLLM::StreamAccumulator#tool_calls_from_stream
7274
message[:tool_calls] = msg.tool_calls.values.map(&:to_h)

lib/agents/runner.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "ostruct"
4+
require "set"
45

56
module Agents
67
# The execution engine that orchestrates conversations between users and agents.
@@ -272,23 +273,39 @@ def deep_copy_context(context)
272273
# @param context_wrapper [RunContext] Context containing conversation history
273274
def restore_conversation_history(chat, context_wrapper)
274275
history = context_wrapper.context[:conversation_history] || []
276+
valid_tool_call_ids = Set.new
275277

276278
history.each do |msg|
277279
next unless restorable_message?(msg)
278280

281+
if msg[:role].to_sym == :tool &&
282+
msg[:tool_call_id] &&
283+
!valid_tool_call_ids.include?(msg[:tool_call_id])
284+
Agents.logger&.warn("Skipping tool message without matching assistant tool_call_id #{msg[:tool_call_id]}")
285+
next
286+
end
287+
279288
message_params = build_message_params(msg)
280289
next unless message_params # Skip invalid messages
281290

282291
message = RubyLLM::Message.new(**message_params)
283292
chat.add_message(message)
293+
294+
if message.role == :assistant && message_params[:tool_calls]
295+
valid_tool_call_ids.merge(message_params[:tool_calls].keys)
296+
end
284297
end
285298
end
286299

287300
# Check if a message should be restored
288301
def restorable_message?(msg)
289302
role = msg[:role].to_sym
290303
return false unless %i[user assistant tool].include?(role)
291-
return false if role != :tool && Helpers::MessageExtractor.content_empty?(msg[:content])
304+
305+
# Allow assistant messages that only contain tool calls (no text content)
306+
tool_calls_present = role == :assistant && msg[:tool_calls] && !msg[:tool_calls].empty?
307+
return false if role != :tool && !tool_calls_present &&
308+
Helpers::MessageExtractor.content_empty?(msg[:content])
292309

293310
true
294311
end
@@ -297,9 +314,13 @@ def restorable_message?(msg)
297314
def build_message_params(msg)
298315
role = msg[:role].to_sym
299316

317+
content_value = msg[:content]
318+
# Assistant tool-call messages may have empty text, but still need placeholder content
319+
content_value = "" if content_value.nil? && role == :assistant && msg[:tool_calls]&.any?
320+
300321
params = {
301322
role: role,
302-
content: RubyLLM::Content.new(msg[:content])
323+
content: RubyLLM::Content.new(content_value)
303324
}
304325

305326
# Handle tool-specific parameters (Tool Results)

spec/agents/helpers/message_extractor_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,46 @@
168168
])
169169
end
170170
end
171+
172+
context "when assistant tool calls have no text content" do
173+
let(:tool_call) do
174+
instance_double(RubyLLM::ToolCall,
175+
to_h: {
176+
id: "call_456",
177+
name: "test_tool",
178+
arguments: { foo: "bar" }
179+
})
180+
end
181+
182+
let(:assistant_with_tool_only) do
183+
instance_double(RubyLLM::Message,
184+
role: :assistant,
185+
content: nil,
186+
tool_call?: true,
187+
tool_calls: { "call_456" => tool_call })
188+
end
189+
190+
let(:chat) { instance_double(RubyLLM::Chat, messages: [assistant_with_tool_only]) }
191+
192+
it "preserves the message and tool calls with empty string content" do
193+
result = described_class.extract_messages(chat, current_agent)
194+
195+
expect(result).to eq([
196+
{
197+
role: :assistant,
198+
content: "",
199+
agent_name: "TestAgent",
200+
tool_calls: [
201+
{
202+
id: "call_456",
203+
name: "test_tool",
204+
arguments: { foo: "bar" }
205+
}
206+
]
207+
}
208+
])
209+
end
210+
end
171211
end
172212

173213
describe ".content_empty?" do

spec/agents/runner_spec.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,40 @@
450450
end
451451
end
452452

453+
context "with assistant tool calls that have empty content" do
454+
let(:context_with_tool_only_assistant) do
455+
{
456+
conversation_history: [
457+
{ role: :user, content: "Trigger a tool" },
458+
{
459+
role: :assistant,
460+
content: "",
461+
tool_calls: [{ id: "call_blank", name: "do_something", arguments: {} }]
462+
},
463+
{ role: :tool, content: "Done", tool_call_id: "call_blank" }
464+
]
465+
}
466+
end
467+
468+
before do
469+
stub_simple_chat("All set")
470+
end
471+
472+
it "restores assistant tool call messages even without text" do
473+
result = runner.run(agent, "Thanks", context: context_with_tool_only_assistant)
474+
475+
expect(result.success?).to be true
476+
477+
assistant_with_tools = result.messages.find do |msg|
478+
msg[:role] == :assistant && msg[:tool_calls]&.any?
479+
end
480+
481+
expect(assistant_with_tools).not_to be_nil
482+
expect(assistant_with_tools[:content]).to eq("")
483+
expect(assistant_with_tools[:tool_calls].first[:id]).to eq("call_blank")
484+
end
485+
end
486+
453487
it "restores tool_calls on assistant messages" do
454488
# As of commit 1cfe99e, tool_calls ARE restored on assistant messages
455489
# because OpenAI/Anthropic APIs require tool result messages to be

0 commit comments

Comments
 (0)