Class: AssistantConversation

Inherits:
ApplicationRecord show all
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

Delegated Instance Attributes collapse

Belongs to collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

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

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#current_sender_idObject

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

#titleObject (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.

Parameters:

  • opportunity_id (Integer, String)

Returns:



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_briefingsActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are daily briefings. Active Record Scope

Returns:

See Also:



397
# File 'app/models/assistant_conversation.rb', line 397

scope :daily_briefings, -> { where("metadata @> ?", { conversation_type: 'daily_briefing' }.to_json) }

.daily_focus_forActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are daily focus for. Active Record Scope

Returns:

See Also:



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_reviewActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are daily focus pending review. Active Record Scope

Returns:

See Also:



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_processingActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are daily focus processing. Active Record Scope

Returns:

See Also:



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_dateObject

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_audienceActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are for daily focus audience. Active Record Scope

Returns:

See Also:



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_dateActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are for daily focus date. Active Record Scope

Returns:

See Also:



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_userActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are for user. Active Record Scope

Returns:

See Also:



395
# File 'app/models/assistant_conversation.rb', line 395

scope :for_user, ->(user) { where(user_id: user.id) }

.opportunity_briefings_forActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are opportunity briefings for. Active Record Scope

Returns:

See Also:



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

.recentActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are recent. Active Record Scope

Returns:

See Also:



394
# File 'app/models/assistant_conversation.rb', line 394

scope :recent, -> { order(updated_at: :desc) }

.retailer_analyses_forActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are retailer analyses for. Active Record Scope

Returns:

See Also:



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)
  message_matches = AssistantMessage.search_content(term)
                                    .reorder(nil)
                                    .select(:assistant_conversation_id)

  where.any_of({ id: title_matches }, { id: message_matches })
end

.shared_withActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are shared with. Active Record Scope

Returns:

See Also:



488
489
490
491
492
493
494
# File 'app/models/assistant_conversation.rb', line 488

scope :shared_with, ->(party, ) {
  where(
    id: AssistantConversationShare
        .granting_access_to(party, )
        .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.

Parameters:

  • content (String)

Returns:

  • (String)


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_byActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are viewable by. Active Record Scope

Returns:

See Also:



497
# File 'app/models/assistant_conversation.rb', line 497

scope :viewable_by, ->(party, ) { for_user(party).or(shared_with(party, )) }

.with_errorsActiveRecord::Relation<AssistantConversation>

A relation of AssistantConversations that are with errors. Active Record Scope

Returns:

See Also:



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, )
  return 'owner' if user_id == party.id

  share = shares.granting_access_to(party, ).order(
    Arel.sql("CASE access_level WHEN 'collaborator' THEN 0 ELSE 1 END")
  ).first
  share&.access_level
end

#add_messageObject

── 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 add_message(...)
  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.

Returns:

  • (self)
  • (void)


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.message}")
end

#continuationsActiveRecord::Relation<AssistantConversation>

Returns:

See Also:



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.

Returns:



276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'app/models/assistant_conversation.rb', line 276

def delete_trailing_blank_assistant_message!
  last_message = assistant_messages.where.not(role: 'system').reorder(id: :desc).first
  return unless last_message&.role == 'assistant' && last_message.content.blank?

  last_message.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_messageAssistantMessage?

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.

Returns:



515
516
517
518
519
520
521
522
# File 'app/models/assistant_conversation.rb', line 515

def last_unanswered_user_message
  substantive_messages = assistant_messages.where("NULLIF(BTRIM(content), '') IS NOT NULL")
  last_user_message = substantive_messages.where(role: 'user').reorder(id: :desc).first
  return unless last_user_message

  last_assistant_message_id = substantive_messages.where(role: 'assistant').maximum(:id)
  last_user_message if last_assistant_message_id.nil? || last_user_message.id > last_assistant_message_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_conversationAssistantConversation



120
# File 'app/models/assistant_conversation.rb', line 120

belongs_to :parent_conversation, class_name: 'AssistantConversation', optional: true

#persist_new_messageObject



181
182
183
184
185
186
187
188
189
# File 'app/models/assistant_conversation.rb', line 181

def persist_new_message
  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 message_record.nil? || !message_record.class.exists?(message_record.id)
    Rails.logger.warn(
      "[AssistantConversation##{id}] Skipping persist_tool_calls — parent " \
      "assistant_message #{message_record&.id.inspect} no longer exists (race; #6043)."
    )
    return
  end

  super
end

#processing_byParty

Returns:

See Also:



119
# File 'app/models/assistant_conversation.rb', line 119

belongs_to :processing_by, class_name: 'Party', optional: true

#provider_optionsObject

Alias for To_llm#provider_options

Returns:

  • (Object)

    To_llm#provider_options

See Also:



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

#sharesActiveRecord::Relation<AssistantConversationShare>

Returns:

See Also:



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.

Parameters:

  • content (String)

Returns:

  • (Boolean)


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.

Parameters:

  • content (String)

Returns:

  • (Array<String>)


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_llmObject

── 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.messages = []

  compaction_summary = Assistant::ContextCompactor.ensure_context_summary!(self)
  cutoff_id          = compaction_summary ? compaction_through_message_id : nil
  msgs               = load_messages_for_replay(cutoff_id)
  orphaned_ids       = detect_orphaned_tool_call_ids(msgs)

  replay_messages_onto_chat(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.

Returns:

  • (Boolean)


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.

Parameters:

  • new_owner (Party)

    the employee taking ownership

Returns:

Raises:

  • (OwnershipTransferRejected)

    if processing started or an answer arrived

  • (ActiveRecord::RecordInvalid)

    if the ownership or share update fails



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

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



121
# File 'app/models/assistant_conversation.rb', line 121

has_many :uploads, as: :resource, dependent: :destroy

#userParty

Returns:

See Also:



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:

  1. frozen prefix — +cache_until_here: true+ → the protocol stamps
    +cache_control+ on its block (TTL from ChatService's +with_caching+)
  2. 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.

Parameters:

  • frozen (String)

    the stable prefix (identity, tools, domain)

  • volatile (String, nil) (defaults to: nil)

    per-turn tail (active plan, planning mandate)

  • cacheable (Boolean) (defaults to: false)

    emit the two-message cached shape

Returns:

  • (self)


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
    assistant_messages.destroy_by(role: :system)

    if cacheable
      assistant_messages.create!(role: :system, content: frozen, cache_until_here: true)
      assistant_messages.create!(role: :system, content: volatile) if volatile.present?
    else
      assistant_messages.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