Module: RubyLLM::ActiveRecord::ChatMethods

Extended by:
ActiveSupport::Concern
Includes:
Enumerable, AttachmentHelpers
Defined in:
lib/ruby_llm/active_record/chat_methods.rb

Overview

ChatMethods provides the RubyLLM::Chat API on ActiveRecord models declared with acts_as_chat, persisting every message to the database. Configuration methods return self so calls can be chained.

class Chat < ApplicationRecord
acts_as_chat
end

chat = Chat.create!(model: 'gpt-5.6-luna')
chat.ask "What is the capital of France?"
chat.messages.count # => 2

Constant Summary collapse

CANCELLATION_POLL_INTERVAL =

:nodoc:

1.0
COMPLETION_ERRORS =

:nodoc:

[ # :nodoc:
  RubyLLM::CancelledError, RubyLLM::Error, Faraday::Error, Timeout::Error, Errno::ETIMEDOUT
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#assume_model_existsObject

When true, skips the model registry lookup so unregistered model ids are accepted. Not persisted; set it again after reloading the record.



36
37
38
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 36

def assume_model_exists
  @assume_model_exists
end

#contextObject

An optional RubyLLM::Context supplying per-chat configuration, used when building the underlying chat. Not persisted; set it again after reloading the record.



46
47
48
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 46

def context
  @context
end

#protocolObject

Overrides the wire protocol the provider would pick for the model, such as :responses or :chat_completions for OpenAI, or nil for the provider default. Not persisted; set it again after reloading the record.



41
42
43
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 41

def protocol
  @protocol
end

Instance Method Details

#add_message(message_or_attributes) ⇒ Object

Persists message_or_attributes as a message record, including any attachments and tool calls. Accepts a RubyLLM::Message, an attributes Hash, or a record responding to to_llm. Returns the message record.

chat.add_message(role: :user, content: long_context)


509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 509

def add_message(message_or_attributes)
  llm_message = message_or_attributes
  llm_message = llm_message.to_llm if llm_message.respond_to?(:to_llm)
  llm_message = RubyLLM::Message.new(llm_message) unless llm_message.is_a?(RubyLLM::Message)

  message_record = messages_association.create!(message_attributes(llm_message))

  if llm_message.tool_call_id && (tool_call = find_tool_call(llm_message.tool_call_id))
    tool_call.update!(result: message_record)
  end

  persist_content(message_record, llm_message.attachments) if llm_message.attachments.any?
  persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?

  @chat&.add_message(llm_message)

  message_record
end

#approve(tool_call) ⇒ Object

Records approval for tool_call (a ToolCall, a tool call record, or an id) on the persisted tool call, so the next #complete executes it from any process. Returns self.

chat.approve(params[:tool_call_id])
CompleteJob.perform_later(chat.id)


75
76
77
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 75

def approve(tool_call)
  record_tool_call_decision(tool_call, 'approved')
end

#ask(message = nil, with: nil) ⇒ Object Also known as: say

Persists message as a user message, then runs the conversation loop and returns the latest assistant RubyLLM::Message. The loop pauses when #awaiting_approval? is true. Yields streaming chunks to a block.

chat.ask "What is the capital of France?"
chat.ask "What's in this file?", with: "diagram.png"


573
574
575
576
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 573

def ask(message = nil, with: nil, &)
  ask_later(message, with: with)
  complete(&)
end

#ask_later(message = nil, with: nil) ⇒ Object

Persists message as a user message without calling the model, so #complete can run later. Returns self.

chat.ask_later "Summarize this document."
chat.complete


586
587
588
589
590
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 586

def ask_later(message = nil, with: nil)
  to_llm.raise_if_pending_tool_calls!
  add_message(role: :user, content: message, attachments: with)
  self
end

#awaiting_approval?Boolean

Returns whether the conversation is waiting on tool calls that require approval and have no recorded decision. See RubyLLM::Chat#awaiting_approval?.

Returns:

  • (Boolean)


90
91
92
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 90

def awaiting_approval?
  to_llm.awaiting_approval?
end

#cache_until_hereObject

Marks the latest persisted message as a prompt cache boundary, or the latest in-memory message when none is persisted yet. Returns self.

chat.with_instructions('Reusable analysis prompt').cache_until_here

Raises ArgumentError if the chat has no messages.



534
535
536
537
538
539
540
541
542
543
544
545
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 534

def cache_until_here
  message_record = messages_association.order(:id).last
  if message_record
    message_record.cache_until_here
  elsif @chat&.messages&.any?
    @chat.cache_until_here
  else
    raise ArgumentError, 'No messages to cache'
  end

  self
end

#cancelObject

Requests cancellation of the current in-flight chat operation. The request is persisted so a background job can observe it from another process.



51
52
53
54
55
56
57
58
59
60
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 51

def cancel
  if persisted?
    update_column(:cancelled, true)
  else
    self[:cancelled] = true
  end

  @chat&.cancel
  self
end

#cancelled?Boolean

Returns whether this record or its memoized in-memory chat has a pending cancellation request.

Returns:

  • (Boolean)


64
65
66
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 64

def cancelled?
  @chat&.cancelled? || self[:cancelled]
end

#compactObject

Compacts the model context and persists its assistant Message without deleting earlier messages. See RubyLLM::Chat#compact.



604
605
606
607
608
609
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 604

def compact
  to_llm.compact
rescue *COMPLETION_ERRORS => e
  cleanup_after_failure(e)
  raise
end

#completeObject

Runs the completion loop on the underlying chat, persisting each message, and returns the latest RubyLLM::Message. Pauses when a tool requires approval. When the API call fails, destroys the empty assistant message and any orphaned tool results, then re-raises the error.



642
643
644
645
646
647
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 642

def complete(...)
  to_llm.complete(...)
rescue *COMPLETION_ERRORS => e
  cleanup_after_failure(e)
  raise
end

#complete?Boolean

Returns whether the conversation has no pending work, neither a response to generate nor tool calls to run. See RubyLLM::Chat#complete?.

Returns:

  • (Boolean)


634
635
636
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 634

def complete?
  to_llm.complete?
end

#costObject

Returns a RubyLLM::Cost aggregating every persisted usage entry, including retries and attempts that did not produce a message.

chat.cost.total


561
562
563
564
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 561

def cost
  records = ruby_llm_usages.to_a
  RubyLLM::Cost.aggregate(records.map(&:cost), complete: records.all?(&:cost_available?))
end

#deny(tool_call) ⇒ Object

Records denial for tool_call (a ToolCall, a tool call record, or an id) on the persisted tool call. The next #complete appends a structured denial result instead of executing the tool. Returns self.



83
84
85
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 83

def deny(tool_call)
  record_tool_call_decision(tool_call, 'denied')
end

#generateObject

Makes a single generation attempt, persists the response, and returns it as a RubyLLM::Message. Tool calls in the response are not executed. See RubyLLM::Chat#generate.



595
596
597
598
599
600
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 595

def generate(...)
  to_llm.generate(...)
rescue *COMPLETION_ERRORS => e
  cleanup_after_failure(e)
  raise
end

#messages_associationObject

:nodoc:



110
111
112
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 110

def messages_association # :nodoc:
  send(messages_association_name)
end

#model=(value) ⇒ Object

Sets the chat's model from an id, a RubyLLM::Model value, or the associated internal model record.

chat.model = 'gpt-5.6-luna'


119
120
121
122
123
124
125
126
127
128
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 119

def model=(value)
  if value.is_a?(RubyLLM::ActiveRecord::Model)
    @pending_model_id = nil
    @pending_provider = nil
    super
  else
    @pending_model_id = value.respond_to?(:id) ? value.id : value
    @pending_provider = value.provider if value.respond_to?(:provider)
  end
end

#model_idObject

Returns the model id of the associated model record, or nil.

chat.model_id # => "gpt-5.6-luna"


139
140
141
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 139

def model_id
  model&.model_id || @pending_model_id
end

#model_id=(value) ⇒ Object

Stores value as the model id, resolved to a model record before save.



131
132
133
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 131

def model_id=(value)
  @pending_model_id = value
end

#nameObject

:method: render :call-seq: render

Returns the next request payload with #before_request hooks applied.



439
440
441
442
443
444
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 439

CHAINABLE_CHAT_DELEGATES.each do |name|
  define_method(name) do |*args, **kwargs, &block|
    to_llm.public_send(name, *args, **kwargs, &block)
    self
  end
end

#pending_approvalsObject

Returns the persisted tool call records that require approval and have no recorded decision, ready to render as approval cards. Pass a record (or its tool_call_id) to #approve or #deny. A record's remote? identifies a provider-executed call.

chat.pending_approvals.each { |record| render record }


101
102
103
104
105
106
107
108
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 101

def pending_approvals
  ids = to_llm.pending_approvals.map(&:id)
  RubyLLM::ActiveRecord::ToolCall.where(
    tool_call_id: ids,
    message_type: self.class.message_class.constantize.polymorphic_name,
    message_id: messages_association.select(:id)
  )
end

#providerObject

Returns the provider of the associated model record, or nil.



150
151
152
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 150

def provider
  model&.provider || @pending_provider
end

#provider=(value) ⇒ Object

Stores value as the provider used when resolving the model id before save.



145
146
147
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 145

def provider=(value)
  @pending_provider = value
end

#reloadObject

Reloads the record from the database, Rails-style, and refreshes the underlying chat's persisted message history to match. Runtime-only configuration such as tools, temperature, and callbacks is preserved. Returns self.



167
168
169
170
171
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 167

def reload(...)
  super
  sync_messages if @chat
  self
end

#run_toolsObject

Executes the pending tool calls and persists their results without calling the model. See RubyLLM::Chat#run_tools. Returns self.



613
614
615
616
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 613

def run_tools
  to_llm.run_tools
  self
end

#stepObject

Advances the conversation by one move: runs the pending tool calls if there are any, otherwise generates a response. Returns nil once the chat is complete or waiting for approval. See RubyLLM::Chat#step.

chat.step until chat.complete? || chat.awaiting_approval?


624
625
626
627
628
629
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 624

def step(...)
  to_llm.step(...)
rescue *COMPLETION_ERRORS => e
  cleanup_after_failure(e)
  raise
end

#to_llmObject

Returns the underlying RubyLLM::Chat for this record, building it on first call and memoizing it. The chat is loaded with the persisted messages and wired to persist new ones. Subsequent calls return the same chat without touching the database; use #reload to refresh its message history from the record.



159
160
161
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 159

def to_llm
  @chat ||= build_llm_chat # rubocop:disable Naming/MemoizedInstanceVariableName
end

#tokensObject

Returns token usage aggregated across every persisted usage entry, including retries and attempts that did not produce a message.

chat.tokens.input


552
553
554
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 552

def tokens
  RubyLLM::Tokens.aggregate(ruby_llm_usages.map(&:tokens))
end

#with_context(value) ⇒ Object

Rebinds the underlying chat to value so subsequent requests use its configuration. Pass nil to return to the global RubyLLM configuration. The Context itself is runtime-only and is not persisted.



177
178
179
180
181
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 177

def with_context(value)
  self.context = value
  @chat&.with_context(value)
  self
end

#with_instructions(instructions, append: false, persist: true, cache_until_here: false) ⇒ Object

Sets the system instructions, persisting them as a message with the :system role. Replaces any persisted system messages unless append: is true. Pass persist: false to apply the instructions only to the in-memory chat for this record instance. With cache_until_here: true the instruction becomes an explicit prompt cache boundary. Returns self.

chat.with_instructions "You are a Ruby expert."
chat.with_instructions "Use short bullet points.", append: true
chat.with_instructions current_context, persist: false


194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 194

def with_instructions(instructions, append: false, persist: true, cache_until_here: false)
  to_llm

  if persist
    if instructions.nil?
      clear_persisted_system_instructions
    else
      persist_system_instruction(instructions, append:, cache_until_here:)
    end
  else
    store_unpersisted_instruction(instructions, append:, cache_until_here:)
  end

  sync_messages
  self
end

#with_model(model_name, provider: nil, protocol: nil, assume_model_exists: false) ⇒ Object

Switches the chat to model_name, resolving and saving the model record and updating the underlying chat. Falls back to the configured default model when model_name is nil. Pass protocol: to override the wire protocol the provider would pick for the model. Returns self.

chat.with_model 'claude-sonnet-5'


490
491
492
493
494
495
496
497
498
499
500
# File 'lib/ruby_llm/active_record/chat_methods.rb', line 490

def with_model(model_name, provider: nil, protocol: nil, assume_model_exists: false)
  model_name ||= (context&.config || RubyLLM.config).default_model
  self.model = model_name
  self.provider = provider if provider
  self.protocol = protocol
  self.assume_model_exists = assume_model_exists
  resolve_model
  save!
  to_llm.with_model(model_id, provider: provider&.to_sym, protocol:, assume_model_exists:)
  self
end