Class: AssistantConversation
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- AssistantConversation
- Includes:
- Models::AssistantConversationMessageReplayable, Models::AssistantConversationPlannable, Models::AssistantConversationProcessingLockable, Models::AssistantConversationTokenTrackable, PgSearch::Model
- Defined in:
- app/models/assistant_conversation.rb
Overview
== Schema Information
Table name: assistant_conversations
Database name: primary
id :bigint not null, primary key
cancelled :boolean default(FALSE), not null
metadata :jsonb not null
processing_since :datetime
title :string default("New Conversation"), not null
created_at :datetime not null
updated_at :datetime not null
parent_conversation_id :bigint
processing_by_id :bigint
ruby_llm_model_id :bigint
user_id :bigint not null
Indexes
index_assistant_conversations_on_metadata (metadata) USING gin
index_assistant_conversations_on_parent_conversation_id (parent_conversation_id)
index_assistant_conversations_on_ruby_llm_model_id (ruby_llm_model_id)
index_assistant_conversations_on_user_id_and_updated_at (user_id,updated_at)
Foreign Keys
fk_rails_... (parent_conversation_id => assistant_conversations.id)
fk_rails_... (ruby_llm_model_id => ruby_llm_models.id)
fk_rails_... (user_id => parties.id)
Defined Under Namespace
Classes: OwnershipTransferRejected
Constant Summary collapse
- LOCK_STALE_AFTER =
Processing lock / plan limits (referenced by workers and initializers)
5.minutes
- HEARTBEAT_STALE_AFTER =
Heartbeat stale after.
3.minutes
- PROCESSING_JID_TTL =
Processing jid ttl.
15.minutes.to_i
- MAX_AUTO_CONTINUATIONS =
Maximum auto continuations for a turn running under a declared plan.
5- MAX_NO_PLAN_AUTO_CONTINUATIONS =
Auto-continuations allowed for a turn that hit the tool-call limit WITHOUT a
declared plan. A single broad question can legitimately need more than
BASE_TOOL_CALLS tool calls (e.g. a cross-source sales recap), so rather than
dead-ending on a "Continue in new conversation" button, let it auto-continue
once. The continuation turn is nudged to declare a plan, which then unlocks
the larger plan budget + MAX_AUTO_CONTINUATIONS. Kept at 1 so a genuinely
stuck no-plan turn still terminates fast and the tool-call ceiling stays an
effective cost backstop. 1
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
-
#current_sender_id ⇒ Object
Set by ChatService before ask() so AssistantMessage.stamp_sender_id can attribute user messages to the actual sender (not the conversation owner).
- #title ⇒ Object readonly
Delegated Instance Attributes collapse
-
#provider_options ⇒ Object
Alias for To_llm#provider_options.
Belongs to collapse
Has many collapse
- #continuations ⇒ ActiveRecord::Relation<AssistantConversation>
- #shares ⇒ ActiveRecord::Relation<AssistantConversationShare>
- #uploads ⇒ ActiveRecord::Relation<Upload>
Class Method Summary collapse
-
.canonical_opportunity_briefing_for(opportunity_id) ⇒ AssistantConversation?
The earliest Sunny briefing thread linked to an opportunity.
-
.daily_briefings ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily briefings.
-
.daily_focus_for ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus for.
-
.daily_focus_pending_review ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus pending review.
-
.daily_focus_processing ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus processing.
-
.earliest_daily_focus_briefing_date ⇒ Object
Earliest calendar day we have a stored daily briefing (for review date picker range).
-
.for_daily_focus_audience ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for daily focus audience.
-
.for_daily_focus_date ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for daily focus date.
-
.for_user ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for user.
-
.opportunity_briefings_for ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are opportunity briefings for.
- .ransackable_associations(_auth_object = nil) ⇒ Object
- .ransackable_attributes(_auth_object = nil) ⇒ Object
-
.recent ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are recent.
-
.retailer_analyses_for ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are retailer analyses for.
-
.search_content(term) ⇒ Object
Search titles and messages independently so PostgreSQL can use each table's GIN index.
-
.shared_with ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are shared with.
-
.template_prompt_digest(content) ⇒ String
Stable fingerprint used to identify an application-generated user turn without storing a second copy of its potentially large prompt.
-
.viewable_by ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are viewable by.
-
.with_errors ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are with errors.
Instance Method Summary collapse
-
#access_level_for(party, account) ⇒ Object
Determine access level for a given party Returns "owner", "collaborator", or "viewer" (nil if no access).
-
#add_message ⇒ Object
── Destroyed-mid-processing guard (AppSignal #6043, second race) ── The owner can delete the conversation (or Daily Focus cleanup destroys the briefing conversation) while AssistantChatWorker is mid-turn.
-
#clear_turn_error! ⇒ self, void
Clears RubyLLM's in-memory chat so the next replay is rebuilt from the persisted message history.
-
#delete_trailing_blank_assistant_message! ⇒ AssistantMessage?
Removes only the trailing blank assistant placeholder left by an interrupted or blank model turn.
-
#last_unanswered_user_message ⇒ AssistantMessage?
Return the latest substantive user message only when no substantive assistant response follows it.
-
#notifiable_parties(except_party_id:) ⇒ Object
Returns all parties who have access to this conversation (owner + all directly shared parties + all parties with a matching role share), excluding the party identified by +except_party_id+.
- #persist_new_message ⇒ Object
-
#persist_tool_calls(tool_calls, message_record: @message) ⇒ Object
── Orphaned tool-call guard (AppSignal #6043) ────────────────── RubyLLM persists a blank assistant message in before_message, then later (after_message → persist_message_completion) saves it and inserts its tool_calls.
- #reset_chat! ⇒ Object
-
#template_prompt?(content) ⇒ Boolean
Whether this exact content was registered as an application-generated turn.
-
#template_prompt_digests_including(content) ⇒ Array<String>
Returns the registered fingerprints plus the supplied automated prompt.
-
#to_llm ⇒ Object
── Context management ────────────────────────────────────────── Override to_llm to eager-load both the application's tool-call record associations and RubyLLM's value-object associations.
-
#tool_limits_enabled? ⇒ Boolean
Per-conversation settings-panel toggle.
-
#transfer_ownership_to!(new_owner) ⇒ AssistantConversation
Transfer ownership while preserving the former owner's ability to access and continue the conversation.
-
#with_system_prompt(frozen:, volatile: nil, cacheable: false) ⇒ self
── System prompt persistence ──────────────────────────────────.
Methods included from Models::AssistantConversationMessageReplayable
#drop_unpaired_tool_results!, #has_thinking_history?
Methods included from Models::AssistantConversationProcessingLockable
#acquire_processing_lock!, #force_processing_lock!, #heartbeat_stale?, #processing?, #processing_job_id, #processing_lock_key, #refresh_processing_ttl!, #release_processing_lock!
Methods included from Models::AssistantConversationTokenTrackable
#computed_token_totals, #computed_total_cost, #logged_cost_usd, #sync_token_totals!, #total_tokens, #track_error!, #track_query!
Methods included from Models::AssistantConversationPlannable
#can_auto_continue?, #increment_continuation_count!, #plan_active?, #plan_completed_steps, #plan_continuation_count, #plan_pending_steps, #plan_progress_summary
Methods inherited from ApplicationRecord
ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Models::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#current_sender_id ⇒ Object
Set by ChatService before ask() so AssistantMessage.stamp_sender_id
can attribute user messages to the actual sender (not the conversation owner).
116 117 118 |
# File 'app/models/assistant_conversation.rb', line 116 def current_sender_id @current_sender_id end |
#title ⇒ Object (readonly)
392 |
# File 'app/models/assistant_conversation.rb', line 392 validates :title, presence: true |
Class Method Details
.canonical_opportunity_briefing_for(opportunity_id) ⇒ AssistantConversation?
The earliest Sunny briefing thread linked to an opportunity.
442 443 444 |
# File 'app/models/assistant_conversation.rb', line 442 def self.canonical_opportunity_briefing_for(opportunity_id) opportunity_briefings_for(opportunity_id).order(:id).first end |
.daily_briefings ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily briefings. Active Record Scope
397 |
# File 'app/models/assistant_conversation.rb', line 397 scope :daily_briefings, -> { where("metadata @> ?", { conversation_type: 'daily_briefing' }.to_json) } |
.daily_focus_for ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus for. Active Record Scope
400 401 402 |
# File 'app/models/assistant_conversation.rb', line 400 scope :daily_focus_for, ->(user, date = Date.current) { where(user_id: user.id).where.contains(metadata: { daily_focus_date: date.iso8601 }) } |
.daily_focus_pending_review ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus pending review. Active Record Scope
403 404 405 406 |
# File 'app/models/assistant_conversation.rb', line 403 scope :daily_focus_pending_review, ->(date = Date.current) { where("metadata @> ?", { daily_focus_date: date.iso8601, daily_focus_status: 'ready' }.to_json) .where("metadata->>'daily_focus_target_employee_id' IS NOT NULL") } |
.daily_focus_processing ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are daily focus processing. Active Record Scope
407 408 409 410 |
# File 'app/models/assistant_conversation.rb', line 407 scope :daily_focus_processing, ->(date = Date.current) { where("metadata @> ?", { daily_focus_date: date.iso8601, daily_focus_status: 'processing' }.to_json) .where("metadata->>'daily_focus_target_employee_id' IS NOT NULL") } |
.earliest_daily_focus_briefing_date ⇒ Object
Earliest calendar day we have a stored daily briefing (for review date picker range).
473 474 475 476 477 |
# File 'app/models/assistant_conversation.rb', line 473 def self.earliest_daily_focus_briefing_date daily_briefings .where("metadata->>'daily_focus_date' IS NOT NULL") .minimum(Arel.sql("(metadata->>'daily_focus_date')::date")) end |
.for_daily_focus_audience ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for daily focus audience. Active Record Scope
416 417 418 419 420 421 422 |
# File 'app/models/assistant_conversation.rb', line 416 scope :for_daily_focus_audience, ->(audience) { if audience.to_s == 'support' where("metadata @> ?", { daily_focus_audience: 'support' }.to_json) else where("COALESCE(metadata->>'daily_focus_audience', 'sales') <> 'support'") end } |
.for_daily_focus_date ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for daily focus date. Active Record Scope
399 |
# File 'app/models/assistant_conversation.rb', line 399 scope :for_daily_focus_date, ->(date) { where("metadata @> ?", { daily_focus_date: date.iso8601 }.to_json) } |
.for_user ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are for user. Active Record Scope
395 |
# File 'app/models/assistant_conversation.rb', line 395 scope :for_user, ->(user) { where(user_id: user.id) } |
.opportunity_briefings_for ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are opportunity briefings for. Active Record Scope
431 432 433 434 435 436 |
# File 'app/models/assistant_conversation.rb', line 431 scope :opportunity_briefings_for, ->(opportunity_id) { where("metadata @> ?", { conversation_type: 'opportunity_briefing', opportunity_briefing_opportunity_id: Integer(opportunity_id) }.to_json) } |
.ransackable_associations(_auth_object = nil) ⇒ Object
483 484 485 |
# File 'app/models/assistant_conversation.rb', line 483 def self.ransackable_associations(_auth_object = nil) %w[user] end |
.ransackable_attributes(_auth_object = nil) ⇒ Object
479 480 481 |
# File 'app/models/assistant_conversation.rb', line 479 def self.ransackable_attributes(_auth_object = nil) %w[title created_at updated_at user_id] end |
.recent ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are recent. Active Record Scope
394 |
# File 'app/models/assistant_conversation.rb', line 394 scope :recent, -> { order(updated_at: :desc) } |
.retailer_analyses_for ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are retailer analyses for. Active Record Scope
425 426 427 428 |
# File 'app/models/assistant_conversation.rb', line 425 scope :retailer_analyses_for, ->(catalog_id) { where("metadata @> ?", { conversation_type: 'retailer_analysis', retailer_catalog_id: catalog_id }.to_json) .order(created_at: :desc) } |
.search_content(term) ⇒ Object
Search titles and messages independently so PostgreSQL can use each table's
GIN index. pg_search's associated_against implementation aggregates every
message in the database before applying conversation filters.
77 78 79 80 81 82 83 84 |
# File 'app/models/assistant_conversation.rb', line 77 def self.search_content(term) title_matches = unscoped.search_title(term).reorder(nil).select(:id) = AssistantMessage.search_content(term) .reorder(nil) .select(:assistant_conversation_id) where.any_of({ id: title_matches }, { id: }) end |
.shared_with ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are shared with. Active Record Scope
488 489 490 491 492 493 494 |
# File 'app/models/assistant_conversation.rb', line 488 scope :shared_with, ->(party, account) { where( id: AssistantConversationShare .granting_access_to(party, account) .select(:assistant_conversation_id) ) } |
.template_prompt_digest(content) ⇒ String
Stable fingerprint used to identify an application-generated user turn
without storing a second copy of its potentially large prompt.
451 452 453 |
# File 'app/models/assistant_conversation.rb', line 451 def self.template_prompt_digest(content) Digest::SHA256.hexdigest(content.to_s) end |
.viewable_by ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are viewable by. Active Record Scope
497 |
# File 'app/models/assistant_conversation.rb', line 497 scope :viewable_by, ->(party, account) { for_user(party).or(shared_with(party, account)) } |
.with_errors ⇒ ActiveRecord::Relation<AssistantConversation>
A relation of AssistantConversations that are with errors. Active Record Scope
396 |
# File 'app/models/assistant_conversation.rb', line 396 scope :with_errors, -> { where('(metadata->>\'error_count\')::int > 0') } |
Instance Method Details
#access_level_for(party, account) ⇒ Object
Determine access level for a given party
Returns "owner", "collaborator", or "viewer" (nil if no access)
501 502 503 504 505 506 507 508 |
# File 'app/models/assistant_conversation.rb', line 501 def access_level_for(party, account) return 'owner' if user_id == party.id share = shares.granting_access_to(party, account).order( Arel.sql("CASE access_level WHEN 'collaborator' THEN 0 ELSE 1 END") ).first share&.access_level end |
#add_message ⇒ Object
── Destroyed-mid-processing guard (AppSignal #6043, second race) ──
The owner can delete the conversation (or Daily Focus cleanup destroys the
briefing conversation) while AssistantChatWorker is mid-turn. The next
persistence insert — add_message for the user/tool message, or
persist_new_message for the blank assistant placeholder — would crash on
PG::ForeignKeyViolation (assistant_messages → assistant_conversations,
fk_rails_efbc57b0b1). Fail fast with Assistant::ConversationDestroyed,
which the worker rescues and exits quietly on: nothing to persist to and
nobody to broadcast to. Signatures mirror the gem's methods (ruby_llm
1.16.0) so super forwards unchanged for the normal case.
The exists? check is not atomic with the insert, so the delete can still
land between the two (TOCTOU). The rescue translates that FK violation
too — but only when the conversation really is gone, so unrelated FK
errors keep surfacing as-is.
171 172 173 174 175 176 177 178 179 |
# File 'app/models/assistant_conversation.rb', line 171 def (...) raise Assistant::ConversationDestroyed unless self.class.exists?(id) super rescue ActiveRecord::InvalidForeignKey raise if self.class.exists?(id) raise Assistant::ConversationDestroyed end |
#clear_turn_error! ⇒ self, void
Clears RubyLLM's in-memory chat so the next replay is rebuilt from the
persisted message history.
Forget the reason the previous turn failed. Called as a turn starts, so the
stuck-turn banner can only ever show a reason belonging to the turn the user
is actually looking at.
241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'app/models/assistant_conversation.rb', line 241 def clear_turn_error! return if ['last_turn_error'].blank? # Removes just these two keys server-side rather than rewriting the whole # blob — `metadata` is shared with every other accessor on this model, and a # read-modify-write here would lose anything written in between. self.class.where(id: id).update_all("metadata = COALESCE(metadata, '{}'::jsonb) - 'last_turn_error' - 'last_turn_error_at'") # Keep the in-memory copy honest — the row just changed underneath it. self. = .except('last_turn_error', 'last_turn_error_at') clear_attribute_changes([:metadata]) rescue StandardError => e Rails.logger.warn("[AssistantConversation##{id}] could not clear turn error: #{e.class}: #{e.}") end |
#continuations ⇒ ActiveRecord::Relation<AssistantConversation>
122 123 124 125 |
# File 'app/models/assistant_conversation.rb', line 122 has_many :continuations, class_name: 'AssistantConversation', foreign_key: :parent_conversation_id, dependent: :nullify, inverse_of: :parent_conversation |
#delete_trailing_blank_assistant_message! ⇒ AssistantMessage?
Removes only the trailing blank assistant placeholder left by an
interrupted or blank model turn.
"Trailing" has to mean trailing. This used to take the last assistant row
and ignore everything after it — but an assistant message that emits tool
calls carries no text, so a turn ending in assistant(tool_use) → tool → tool
matched, and destroying it orphaned every tool result behind it. The retry
then shipped tool_result blocks with no tool_use and Anthropic rejected
the whole request: "unexpected tool_use_id found in tool_result blocks"
(AppSignal #3808 — raised from the blank-response and transient-400 recovery
paths that call this).
System rows are excluded because +with_system_prompt+ destroys and recreates
them every turn, so they always hold the highest ids.
276 277 278 279 280 281 282 283 284 285 286 287 288 |
# File 'app/models/assistant_conversation.rb', line 276 def = .where.not(role: 'system').reorder(id: :desc).first return unless &.role == 'assistant' && .content.blank? .destroy rescue StandardError => e Rails.event.notify( 'warning.assistant.blank_message_cleanup_failed', conversation_id: id, exception_class: e.class.name ) nil end |
#last_unanswered_user_message ⇒ AssistantMessage?
Return the latest substantive user message only when no substantive
assistant response follows it. Blank assistant placeholders are ignored
because interrupted RubyLLM turns can persist one before failing.
515 516 517 518 519 520 521 522 |
# File 'app/models/assistant_conversation.rb', line 515 def = .where("NULLIF(BTRIM(content), '') IS NOT NULL") = .where(role: 'user').reorder(id: :desc).first return unless = .where(role: 'assistant').maximum(:id) if .nil? || .id > end |
#notifiable_parties(except_party_id:) ⇒ Object
Returns all parties who have access to this conversation (owner + all
directly shared parties + all parties with a matching role share),
excluding the party identified by +except_party_id+.
Used by AssistantMessageNotifier to determine notification recipients.
555 556 557 558 559 560 561 562 563 564 565 566 567 568 |
# File 'app/models/assistant_conversation.rb', line 555 def notifiable_parties(except_party_id:) parties = [user] shares.for_users.includes(:user).find_each { |share| parties << share.user } role_ids = shares.for_roles.pluck(:role_id) if role_ids.any? Account.where.overlap(inherited_role_ids: role_ids) .includes(:party) .find_each { |admin| parties << admin.party if admin.party } end parties.compact.uniq.reject { |party| party.id == except_party_id } end |
#parent_conversation ⇒ AssistantConversation
120 |
# File 'app/models/assistant_conversation.rb', line 120 belongs_to :parent_conversation, class_name: 'AssistantConversation', optional: true |
#persist_new_message ⇒ Object
181 182 183 184 185 186 187 188 189 |
# File 'app/models/assistant_conversation.rb', line 181 def raise Assistant::ConversationDestroyed unless self.class.exists?(id) super rescue ActiveRecord::InvalidForeignKey raise if self.class.exists?(id) raise Assistant::ConversationDestroyed end |
#persist_tool_calls(tool_calls, message_record: @message) ⇒ Object
── Orphaned tool-call guard (AppSignal #6043) ──────────────────
RubyLLM persists a blank assistant message in before_message, then later
(after_message → persist_message_completion) saves it and inserts its
tool_calls. A recovery path can destroy that blank message in between —
delete_trailing_blank_assistant_message! (transient-400 retry,
blank-after-tools replay) or the gem's own cleanup_orphaned_tool_results.
The gem's @message.save! is then a silent no-op UPDATE (0 rows, no error),
and persist_tool_calls would INSERT assistant_tool_calls pointing at the
deleted assistant_message_id → PG::ForeignKeyViolation, crashing the worker.
Skip the orphaned insert: the parent turn is gone, so its tool calls have
nothing to attach to. Signature mirrors the gem's private method so super
forwards unchanged for the normal (parent intact) case.
144 145 146 147 148 149 150 151 152 153 154 |
# File 'app/models/assistant_conversation.rb', line 144 def persist_tool_calls(tool_calls, message_record: @message) if .nil? || !.class.exists?(.id) Rails.logger.warn( "[AssistantConversation##{id}] Skipping persist_tool_calls — parent " \ "assistant_message #{&.id.inspect} no longer exists (race; #6043)." ) return end super end |
#processing_by ⇒ Party
119 |
# File 'app/models/assistant_conversation.rb', line 119 belongs_to :processing_by, class_name: 'Party', optional: true |
#provider_options ⇒ Object
Alias for To_llm#provider_options
109 |
# File 'app/models/assistant_conversation.rb', line 109 delegate :provider_options, to: :to_llm, allow_nil: true |
#reset_chat! ⇒ Object
255 256 257 258 |
# File 'app/models/assistant_conversation.rb', line 255 def reset_chat! @chat = nil self end |
#shares ⇒ ActiveRecord::Relation<AssistantConversationShare>
126 |
# File 'app/models/assistant_conversation.rb', line 126 has_many :shares, class_name: 'AssistantConversationShare', dependent: :destroy |
#template_prompt?(content) ⇒ Boolean
Whether this exact content was registered as an application-generated turn.
467 468 469 470 |
# File 'app/models/assistant_conversation.rb', line 467 def template_prompt?(content) content.present? && Array(template_prompt_digests).include?(self.class.template_prompt_digest(content)) end |
#template_prompt_digests_including(content) ⇒ Array<String>
Returns the registered fingerprints plus the supplied automated prompt.
459 460 461 |
# File 'app/models/assistant_conversation.rb', line 459 def template_prompt_digests_including(content) (Array(template_prompt_digests) + [self.class.template_prompt_digest(content)]).uniq end |
#to_llm ⇒ Object
── Context management ──────────────────────────────────────────
Override to_llm to eager-load both the application's tool-call record
associations and RubyLLM's value-object associations. Replay integrity uses
the former, while AssistantMessage#to_llm uses the latter; loading both keeps
conversation replay query-free.
Also integrates sliding-window compaction: when context exceeds the token
threshold, older messages are replaced with a cached summary and only
recent messages are sent verbatim.
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'app/models/assistant_conversation.rb', line 299 def to_llm model_record = model # No model assigned yet — return nil @chat without crashing. # Hit by RubyLLM::Agent.apply_configuration during create!/find before # ChatService assigns a model via with_model(). Safe because apply_configuration # ignores the return value when no macros are declared on SunnyAgent. return @chat if model_record.nil? # assume_model_exists: true lets RubyLLM call the API with any model ID # that is stored in llm_models, even if RubyLLM's bundled models.json # registry hasn't caught up with a newly-released Anthropic model yet. # configure_conversation already uses assume_model_exists: true for the same reason. if @chat.nil? @chat = (context || RubyLLM).chat( model: model_record.model_id, provider: model_record.provider.to_sym, assume_model_exists: true ) # ONCE per chat, never per to_llm call: RubyLLM 2.0's persistence hooks # are the ADDITIVE before_message/after_message callbacks (the # overriding on_* pair was removed), so re-installing them on every # to_llm would stack duplicate handlers and persist each message once # per replay. send(:install_persistence_callbacks, @chat) end # 2.0 dropped reset_messages! in favour of the transcript-replacement # setter; the replay below rebuilds the whole list from DB either way. @chat. = [] compaction_summary = Assistant::ContextCompactor.ensure_context_summary!(self) cutoff_id = compaction_summary ? : nil msgs = (cutoff_id) orphaned_ids = detect_orphaned_tool_call_ids(msgs) (msgs, compaction_summary, orphaned_ids) @chat end |
#tool_limits_enabled? ⇒ Boolean
Per-conversation settings-panel toggle. Rows created before the flag
existed have no metadata key; the jsonb default already reads them as
true, and this predicate is the single read path for the guard.
388 389 390 |
# File 'app/models/assistant_conversation.rb', line 388 def tool_limits_enabled? tool_limits_enabled != false end |
#transfer_ownership_to!(new_owner) ⇒ AssistantConversation
Transfer ownership while preserving the former owner's ability to access
and continue the conversation. Any redundant direct share for the new
owner is removed because ownership already grants full access.
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 |
# File 'app/models/assistant_conversation.rb', line 532 def transfer_ownership_to!(new_owner) with_lock do raise OwnershipTransferRejected, :processing if processing? raise OwnershipTransferRejected, :answered unless next if user_id == new_owner.id previous_owner_id = user_id shares.where(party_id: new_owner.id).delete_all shares.find_or_initialize_by(party_id: previous_owner_id).update!( access_level: 'collaborator', shared_by: new_owner ) update!(user: new_owner) end self end |
#uploads ⇒ ActiveRecord::Relation<Upload>
121 |
# File 'app/models/assistant_conversation.rb', line 121 has_many :uploads, as: :resource, dependent: :destroy |
#user ⇒ Party
118 |
# File 'app/models/assistant_conversation.rb', line 118 belongs_to :user, class_name: 'Party', inverse_of: :assistant_conversations |
#with_system_prompt(frozen:, volatile: nil, cacheable: false) ⇒ self
── System prompt persistence ──────────────────────────────────
Persist Sunny's system prompt as the system MESSAGES RubyLLM 2.0 renders
from, replacing the turn's previous set.
Anthropic prompt caching (+cacheable+) splits the prompt in two so per-turn
content can change without invalidating the large cached prefix:
- frozen prefix — +cache_until_here: true+ → the protocol stamps
+cache_control+ on its block (TTL from ChatService's +with_caching+) - volatile tail — unmarked, renders AFTER the breakpoint
Each :system message becomes its own block in the payload's +system+ array
(Protocols::Anthropic::Chat#build_system_content), so the two-block payload
shape is now produced natively — no Content::Raw, and none of the 1.x
persistence workarounds it required. Non-caching providers keep the single
joined message they always got.
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'app/models/assistant_conversation.rb', line 213 def with_system_prompt(frozen:, volatile: nil, cacheable: false) transaction do .destroy_by(role: :system) if cacheable .create!(role: :system, content: frozen, cache_until_here: true) .create!(role: :system, content: volatile) if volatile.present? else .create!(role: :system, content: [frozen, volatile].compact_blank.join("\n\n")) end end # Rebuild @chat from DB so it replays the persisted system messages (and # their cache_until_here flags). Do NOT also call @chat.with_instructions — # the replay already includes them. to_llm self end |