Skip to content

Commit faf737d

Browse files
timarocrmne
andcommitted
Updated acts_as_* helpers to use canonical 'rails-style' foreign keys (#151)
This updates the acts_as_message, acts_as_chat and acts_as_tool class methods to use Rails-style foreign keys whenever custom class names are used as options. For example: ``` class FooMessage < ActiveRecord::Base acts_as_message chat_class: 'FooChat', tool_call_class: 'FooToolCall' end ``` will now set the foreign key on the `belongs_to :chat` association to be `foo_chat_id`, instead of `chat_id`, and will set the foreign key on `belongs_to :parent_tool_call` association to `foo_tool_call_id` instead of just `tool_call_id`. This is consistent with Rails' naming conventions for class names and foreign keys. Changes are backwards-compatible with existing code/behavior, and don't require a major or minor version bump. Updated test cases to ensure that the associations are working, but didn't re-record VCR tests, since I don't have an OpenAPI key. Closes #150 Co-authored-by: Carmine Paolino <[email protected]>
1 parent 4d52336 commit faf737d

File tree

2 files changed

+95
-24
lines changed

2 files changed

+95
-24
lines changed

lib/ruby_llm/active_record/acts_as.rb

+27-8
Original file line numberDiff line numberDiff line change
@@ -28,28 +28,41 @@ def acts_as_message(chat_class: 'Chat', tool_call_class: 'ToolCall', touch_chat:
2828
include MessageMethods
2929

3030
@chat_class = chat_class.to_s
31+
@chat_foreign_key = "#{@chat_class.underscore}_id"
3132
@tool_call_class = tool_call_class.to_s
33+
@tool_call_foreign_key = "#{@tool_call_class.underscore}_id"
3234

33-
belongs_to :chat, class_name: @chat_class, touch: touch_chat
34-
has_many :tool_calls, class_name: @tool_call_class, dependent: :destroy
35+
belongs_to :chat,
36+
class_name: @chat_class,
37+
foreign_key: @chat_foreign_key,
38+
inverse_of: :messages,
39+
touch: touch_chat
40+
41+
has_many :tool_calls,
42+
class_name: @tool_call_class,
43+
dependent: :destroy
3544

3645
belongs_to :parent_tool_call,
3746
class_name: @tool_call_class,
38-
foreign_key: 'tool_call_id',
47+
foreign_key: @tool_call_foreign_key,
3948
optional: true,
4049
inverse_of: :result
4150

4251
delegate :tool_call?, :tool_result?, :tool_results, to: :to_llm
4352
end
4453

45-
def acts_as_tool_call(message_class: 'Message')
54+
def acts_as_tool_call(message_class: 'Message') # rubocop:disable Metrics/MethodLength
4655
@message_class = message_class.to_s
56+
@message_foreign_key = "#{@message_class.underscore}_id"
4757

48-
belongs_to :message, class_name: @message_class
58+
belongs_to :message,
59+
class_name: @message_class,
60+
foreign_key: @message_foreign_key,
61+
inverse_of: :tool_calls
4962

5063
has_one :result,
5164
class_name: @message_class,
52-
foreign_key: 'tool_call_id',
65+
foreign_key: @message_foreign_key,
5366
inverse_of: :parent_tool_call,
5467
dependent: :nullify
5568
end
@@ -159,14 +172,15 @@ def persist_message_completion(message) # rubocop:disable Metrics/AbcSize,Metric
159172
end
160173

161174
transaction do
162-
@message.update!(
175+
@message.update(
163176
role: message.role,
164177
content: message.content,
165178
model_id: message.model_id,
166-
tool_call_id: tool_call_id,
167179
input_tokens: message.input_tokens,
168180
output_tokens: message.output_tokens
169181
)
182+
@message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
183+
@message.save!
170184
persist_tool_calls(message.tool_calls) if message.tool_calls.present?
171185
end
172186
end
@@ -185,6 +199,11 @@ def persist_tool_calls(tool_calls)
185199
module MessageMethods
186200
extend ActiveSupport::Concern
187201

202+
class_methods do
203+
attr_reader :chat_class, :tool_call_class
204+
attr_reader :chat_foreign_key, :tool_call_foreign_key
205+
end
206+
188207
def to_llm
189208
RubyLLM::Message.new(
190209
role: role.to_sym,

spec/ruby_llm/active_record/acts_as_spec.rb

+68-16
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@
1919
t.timestamps
2020
end
2121

22+
# the Bot* classes are used to test the class
23+
# renaming functionality of acts_as_*
24+
# They are supposed to be identical to the
25+
# non-Bot* classes, but with a different names
26+
# using Rails-canonical naming conventions.
27+
create_table :bot_chats do |t|
28+
t.string :model_id
29+
t.timestamps
30+
end
31+
2232
create_table :messages do |t|
2333
t.references :chat
2434
t.string :role
@@ -30,13 +40,32 @@
3040
t.timestamps
3141
end
3242

43+
create_table :bot_messages do |t|
44+
t.references :bot_chat
45+
t.string :role
46+
t.text :content
47+
t.string :model_id
48+
t.integer :input_tokens
49+
t.integer :output_tokens
50+
t.references :bot_tool_call
51+
t.timestamps
52+
end
53+
3354
create_table :tool_calls do |t|
3455
t.references :message
3556
t.string :tool_call_id
3657
t.string :name
3758
t.json :arguments
3859
t.timestamps
3960
end
61+
62+
create_table :bot_tool_calls do |t|
63+
t.references :bot_message
64+
t.string :tool_call_id
65+
t.string :name
66+
t.json :arguments
67+
t.timestamps
68+
end
4069
end
4170
end
4271

@@ -45,16 +74,31 @@ class Chat < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
4574
acts_as_chat
4675
end
4776

77+
class BotChat < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
78+
include RubyLLM::ActiveRecord::ActsAs
79+
acts_as_chat message_class: 'BotMessage', tool_call_class: 'BotToolCall'
80+
end
81+
4882
class Message < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
4983
include RubyLLM::ActiveRecord::ActsAs
5084
acts_as_message
5185
end
5286

87+
class BotMessage < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
88+
include RubyLLM::ActiveRecord::ActsAs
89+
acts_as_message chat_class: 'BotChat', tool_call_class: 'BotToolCall'
90+
end
91+
5392
class ToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
5493
include RubyLLM::ActiveRecord::ActsAs
5594
acts_as_tool_call
5695
end
5796

97+
class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
98+
include RubyLLM::ActiveRecord::ActsAs
99+
acts_as_tool_call message_class: 'BotMessage'
100+
end
101+
58102
class Calculator < RubyLLM::Tool # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
59103
description 'Performs basic arithmetic'
60104

@@ -71,19 +115,23 @@ def execute(expression:)
71115

72116
shared_examples 'a chainable chat method' do |method_name, *args|
73117
it "returns a Chat instance for ##{method_name}" do
74-
chat = Chat.create!(model_id: 'gpt-4.1-nano')
75-
result = chat.public_send(method_name, *args)
76-
expect(result).to be_a(Chat)
118+
[Chat, BotChat].each do |chat_class|
119+
chat = chat_class.create!(model_id: 'gpt-4.1-nano')
120+
result = chat.public_send(method_name, *args)
121+
expect(result).to be_a(chat_class)
122+
end
77123
end
78124
end
79125

80126
shared_examples 'a chainable callback method' do |callback_name|
81-
it "supports #{callback_name} callback" do
82-
chat = Chat.create!(model_id: 'gpt-4.1-nano')
83-
result = chat.public_send(callback_name) do
84-
# no-op for testing
127+
it "supports #{callback_name} callback" do # rubocop:disable RSpec/ExampleLength
128+
[Chat, BotChat].each do |chat_class|
129+
chat = chat_class.create!(model_id: 'gpt-4.1-nano')
130+
result = chat.public_send(callback_name) do
131+
# no-op for testing
132+
end
133+
expect(result).to be_a(chat_class)
85134
end
86-
expect(result).to be_a(Chat)
87135
end
88136
end
89137

@@ -124,9 +172,11 @@ def execute(expression:)
124172

125173
describe 'with_tools functionality' do
126174
it 'returns a Chat instance when using with_tool' do
127-
chat = Chat.create!(model_id: 'gpt-4.1-nano')
128-
with_tool_result = chat.with_tool(Calculator)
129-
expect(with_tool_result).to be_a(Chat)
175+
[Chat, BotChat].each do |chat_class|
176+
chat = chat_class.create!(model_id: 'gpt-4.1-nano')
177+
with_tool_result = chat.with_tool(Calculator)
178+
expect(with_tool_result).to be_a(chat_class)
179+
end
130180
end
131181

132182
it 'persists user messages' do
@@ -145,11 +195,13 @@ def execute(expression:)
145195
it_behaves_like 'a chainable callback method', :on_new_message
146196
it_behaves_like 'a chainable callback method', :on_end_message
147197

148-
it 'supports method chaining with tools' do
149-
chat = Chat.create!(model_id: 'gpt-4.1-nano')
150-
chat.with_tool(Calculator)
151-
.with_temperature(0.5)
152-
expect(chat).to be_a(Chat)
198+
it 'supports method chaining with tools' do # rubocop:disable RSpec/ExampleLength
199+
[Chat, BotChat].each do |chat_class|
200+
chat = chat_class.create!(model_id: 'gpt-4.1-nano')
201+
chat.with_tool(Calculator)
202+
.with_temperature(0.5)
203+
expect(chat).to be_a(chat_class)
204+
end
153205
end
154206

155207
it 'persists messages after chaining' do

0 commit comments

Comments
 (0)