Module: Legion::LLM::ConversationStore

Extended by:
Legion::Logging::Helper
Defined in:
lib/legion/llm/conversation_store.rb

Constant Summary collapse

MAX_CONVERSATIONS =
256
METADATA_ROLE =
:__metadata__

Class Method Summary collapse

Class Method Details

.append(conversation_id, role:, content:, parent_id: nil, sidechain: false, message_group_id: nil, agent_id: nil, **metadata) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/legion/llm/conversation_store.rb', line 15

def append(conversation_id, role:, content:, parent_id: nil, sidechain: false,
           message_group_id: nil, agent_id: nil, **)
  ensure_conversation(conversation_id)
  id  = SecureRandom.uuid
  seq = next_seq(conversation_id)
  msg = {
    id:               id,
    seq:              seq,
    role:             role,
    content:          content,
    parent_id:        parent_id,
    sidechain:        sidechain,
    message_group_id: message_group_id,
    agent_id:         agent_id,
    created_at:       Time.now,
    **
  }
  conversations[conversation_id][:messages] << msg
  touch(conversation_id)
  persist_message(conversation_id, msg)
  msg
end

.branch(conversation_id, from_message_id:) ⇒ Object

Create a new conversation branched from from_message_id. Copies all messages up to and including that message into a new conversation.

Raises:

  • (ArgumentError)


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/legion/llm/conversation_store.rb', line 69

def branch(conversation_id, from_message_id:)
  raw = all_raw_messages(conversation_id)
  target = raw.find { |m| m[:id] == from_message_id }
  raise ArgumentError, "Message #{from_message_id} not found in #{conversation_id}" unless target

  chain = reconstruct_chain(raw)
  # Only keep messages up to (and including) the target message by seq
  cutoff_seq = target[:seq]
  prefix = chain.select { |m| m[:seq] <= cutoff_seq }

  new_id = SecureRandom.uuid
  create_conversation(new_id)
  prefix.each_with_index do |msg, i|
    new_msg = msg.merge(seq: i + 1, id: SecureRandom.uuid, parent_id: nil, created_at: Time.now)
    conversations[new_id][:messages] << new_msg
    persist_message(new_id, new_msg)
  end
  touch(new_id)
  new_id
end

.build_chain(conversation_id, include_sidechains: false) ⇒ Object

Build ordered chain from parent links. Excludes sidechain messages by default.



52
53
54
55
56
57
# File 'lib/legion/llm/conversation_store.rb', line 52

def build_chain(conversation_id, include_sidechains: false)
  raw = all_raw_messages(conversation_id)
  raw = raw.reject { |m| m[:sidechain] } unless include_sidechains
  raw = raw.reject { |m| m[:role] == METADATA_ROLE }
  reconstruct_chain(raw)
end

.cancel_skill!(conversation_id) ⇒ Object

Reads current state, clears :skill_state, sets :skill_cancelled flag. Returns the previous state (for use in skill.cancelled payload), or nil if none.



172
173
174
175
176
177
178
179
180
# File 'lib/legion/llm/conversation_store.rb', line 172

def cancel_skill!(conversation_id)
  ensure_conversation(conversation_id)
  state = conversations[conversation_id].delete(:skill_state)
  if state
    conversations[conversation_id][:skill_cancelled] = true
    touch(conversation_id)
  end
  state
end

.clear_cancel_flag(conversation_id) ⇒ Object



190
191
192
193
194
195
# File 'lib/legion/llm/conversation_store.rb', line 190

def clear_cancel_flag(conversation_id)
  return unless in_memory?(conversation_id)

  conversations[conversation_id].delete(:skill_cancelled)
  touch(conversation_id)
end

.clear_skill_state(conversation_id) ⇒ Object



163
164
165
166
167
168
# File 'lib/legion/llm/conversation_store.rb', line 163

def clear_skill_state(conversation_id)
  return unless in_memory?(conversation_id)

  conversations[conversation_id].delete(:skill_state)
  touch(conversation_id)
end

.conversation_exists?(conversation_id) ⇒ Boolean

Returns:

  • (Boolean)


137
138
139
# File 'lib/legion/llm/conversation_store.rb', line 137

def conversation_exists?(conversation_id)
  in_memory?(conversation_id) || db_conversation_exists?(conversation_id)
end

.create_conversation(conversation_id, **metadata) ⇒ Object



123
124
125
126
127
# File 'lib/legion/llm/conversation_store.rb', line 123

def create_conversation(conversation_id, **)
  conversations[conversation_id] = { messages: [], metadata: , lru_tick: next_tick }
  evict_if_needed
  persist_conversation(conversation_id, )
end

.in_memory?(conversation_id) ⇒ Boolean

Returns:

  • (Boolean)


141
142
143
# File 'lib/legion/llm/conversation_store.rb', line 141

def in_memory?(conversation_id)
  conversations.key?(conversation_id)
end

.messages(conversation_id) ⇒ Object

Returns flat ordered message array — backward-compatible. Uses chain reconstruction when parent links exist; falls back to seq order.



40
41
42
43
44
45
46
47
48
# File 'lib/legion/llm/conversation_store.rb', line 40

def messages(conversation_id)
  if in_memory?(conversation_id)
    touch(conversation_id)
    raw = conversations[conversation_id][:messages].reject { |m| m[:role] == METADATA_ROLE }
    chain_or_seq(raw)
  else
    load_from_db(conversation_id)
  end
end

.migrate_parent_links!(conversation_id) ⇒ Object

Migrate existing sequential messages to use parent links. Safe to call on already-migrated data (no-op when parent links present).



199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/legion/llm/conversation_store.rb', line 199

def migrate_parent_links!(conversation_id)
  ensure_conversation(conversation_id)
  msgs = conversations[conversation_id][:messages].sort_by { |m| m[:seq] }
  return if msgs.empty?
  return if msgs.any? { |m| m[:parent_id] }

  prev_id = nil
  msgs.each do |msg|
    msg[:parent_id] = prev_id
    prev_id = msg[:id] ||= SecureRandom.uuid
  end

  touch(conversation_id)
end

.read_metadata(conversation_id, tail_n: 20) ⇒ Object

Read metadata stored by store_metadata; scans tail of message list.



112
113
114
115
116
117
118
119
120
121
# File 'lib/legion/llm/conversation_store.rb', line 112

def (conversation_id, tail_n: 20)
  raw = all_raw_messages(conversation_id)
  tail = raw.last(tail_n).select { |m| m[:role] == METADATA_ROLE }
  return nil if tail.empty?

  entry = tail.last
  ::JSON.parse(entry[:content], symbolize_names: true)
rescue ::JSON::ParserError
  nil
end

.replace(conversation_id, messages) ⇒ Object



129
130
131
132
133
134
135
# File 'lib/legion/llm/conversation_store.rb', line 129

def replace(conversation_id, messages)
  ensure_conversation(conversation_id)
  conversations[conversation_id][:messages] = messages.each_with_index.map do |msg, i|
    msg.merge(seq: i + 1, created_at: msg[:created_at] || Time.now)
  end
  touch(conversation_id)
end

.reset!Object



145
146
147
148
# File 'lib/legion/llm/conversation_store.rb', line 145

def reset!
  @conversations = {}
  @lru_counter   = 0
end

.set_skill_state(conversation_id, skill_key:, resume_at:) ⇒ Object



150
151
152
153
154
# File 'lib/legion/llm/conversation_store.rb', line 150

def set_skill_state(conversation_id, skill_key:, resume_at:)
  ensure_conversation(conversation_id)
  conversations[conversation_id][:skill_state] = { skill_key: skill_key, resume_at: resume_at }
  touch(conversation_id)
end

.sidechain_messages(conversation_id, agent_id: nil) ⇒ Object

Return sidechain messages; optionally filter by agent_id.



60
61
62
63
64
65
# File 'lib/legion/llm/conversation_store.rb', line 60

def sidechain_messages(conversation_id, agent_id: nil)
  raw = all_raw_messages(conversation_id)
  result = raw.select { |m| m[:sidechain] && m[:role] != METADATA_ROLE }
  result = result.select { |m| m[:agent_id] == agent_id } unless agent_id.nil?
  result.sort_by { |m| m[:seq] }
end

.skill_cancelled?(conversation_id) ⇒ Boolean

:skill_cancelled is distinct from a nil :skill_state. nil skill_state also occurs after normal completion — use this flag to detect cancel.

Returns:

  • (Boolean)


184
185
186
187
188
# File 'lib/legion/llm/conversation_store.rb', line 184

def skill_cancelled?(conversation_id)
  return false unless in_memory?(conversation_id)

  conversations[conversation_id][:skill_cancelled] == true
end

.skill_state(conversation_id) ⇒ Object



156
157
158
159
160
161
# File 'lib/legion/llm/conversation_store.rb', line 156

def skill_state(conversation_id)
  return nil unless in_memory?(conversation_id)

  touch(conversation_id)
  conversations[conversation_id][:skill_state]&.dup
end

.store_metadata(conversation_id, title: nil, tags: nil, model: nil) ⇒ Object

Store session metadata as a special entry (tail-window pattern).



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/legion/llm/conversation_store.rb', line 91

def (conversation_id, title: nil, tags: nil, model: nil)
  ensure_conversation(conversation_id)
  payload = { title: title, tags: tags, model: model }.compact
  msg = {
    id:               SecureRandom.uuid,
    seq:              next_seq(conversation_id),
    role:             METADATA_ROLE,
    content:          payload.to_json,
    parent_id:        nil,
    sidechain:        false,
    message_group_id: nil,
    agent_id:         nil,
    created_at:       Time.now
  }
  conversations[conversation_id][:messages] << msg
  touch(conversation_id)
  persist_message(conversation_id, msg)
  msg
end