Class: Clacky::MessageHistory

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/message_history.rb

Overview

MessageHistory wraps the conversation message list and exposes business-meaningful operations instead of raw array manipulation.

Internal fields (task_id, created_at, system_injected, etc.) are kept in the internal store but stripped when calling #to_api.

Constant Summary collapse

INTERNAL_FIELDS =

Fields that are internal to the agent and must not be sent to the API.

%i[
  task_id created_at system_injected session_context memory_update
  subagent_instructions subagent_result subagent_transcript token_usage
  compressed_summary chunk_path truncated transient
  chunk_index chunk_count ext_events skill_command skill_command_display
  display_references
].freeze
MAX_EXT_EVENTS_PER_MESSAGE =

Cap on persisted ext_events per message. These are milestone events (progress chatter is transient), so a handful per message is the norm — the cap only exists to stop a runaway extension. Oldest are dropped first.

50
MAX_EXT_EVENT_BYTES =

Oversized payloads belong in a file, not in session.json (which is rewritten in full on every save).

8 * 1024

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(messages = []) ⇒ MessageHistory

Returns a new instance of MessageHistory.



30
31
32
# File 'lib/clacky/message_history.rb', line 30

def initialize(messages = [])
  @messages = messages.dup
end

Class Method Details

.pad_reasoning_content_if_needed(msgs, force: false) ⇒ Object

Public helper: pad assistant messages that lack a reasoning_content field with an empty string, either when forced or when the payload already shows evidence of thinking-mode (at least one assistant message with reasoning_content).

Exposed as a class method so Time Machine's active_messages path can reuse the exact same logic without routing through #to_api.



474
475
476
477
478
479
480
481
482
483
484
# File 'lib/clacky/message_history.rb', line 474

def self.pad_reasoning_content_if_needed(msgs, force: false)
  should_pad = force || msgs.any? { |m| m[:role] == "assistant" && m[:reasoning_content] }
  return msgs unless should_pad

  msgs.map do |m|
    next m unless m[:role] == "assistant"
    next m if m.key?(:reasoning_content)

    m.merge(reasoning_content: "")
  end
end

Instance Method Details

#append(message) ⇒ Object

Append a single message hash to the history.

When appending a user message, automatically drop any trailing assistant message that has unanswered tool_calls (no tool_result follows it). This prevents API error 2013 ("tool call result does not follow tool call") when a previous task ended before observe() could append tool results (e.g. subagent crash, interrupt, or error).



45
46
47
48
49
50
51
# File 'lib/clacky/message_history.rb', line 45

def append(message)
  if message[:role] == "user"
    drop_dangling_tool_calls!
  end
  @messages << deep_sanitize_utf8(message)
  self
end

#append_ext_event(event) ⇒ Object

Append a custom extension event onto the most recent message so it is persisted with the session and can be replayed after a reload. Events are anchored to the last message: on replay they are re-emitted right after that message, preserving chronological order. No-op when the history is still empty (nothing to anchor to).



142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/clacky/message_history.rb', line 142

def append_ext_event(event)
  msg = @messages.last
  return self unless msg

  if JSON.generate(event).bytesize > MAX_EXT_EVENT_BYTES
    Clacky::Logger.warn("ext event #{event[:type].inspect} dropped: payload exceeds #{MAX_EXT_EVENT_BYTES} bytes")
    return self
  end

  list = (msg[:ext_events] ||= [])
  list.shift while list.size >= MAX_EXT_EVENTS_PER_MESSAGE
  list << deep_sanitize_utf8(event)
  self
end

#attach_to_tool_result(tool_call_id, key, value) ⇒ Object

Attach a key/value onto the most recent tool result message matching the given tool_call_id. Handles both OpenAI-style (role:"tool", tool_call_id) and Anthropic-style (role:"user" with tool_result blocks) messages. No-op if no matching message is found.



90
91
92
93
94
95
96
97
98
# File 'lib/clacky/message_history.rb', line 90

def attach_to_tool_result(tool_call_id, key, value)
  msg = @messages.reverse.find do |m|
    (m[:role] == "tool" && m[:tool_call_id] == tool_call_id) ||
      (m[:role] == "user" && m[:content].is_a?(Array) &&
        m[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" && b[:tool_use_id] == tool_call_id })
  end
  msg[key] = value if msg
  self
end

#delete_where(&block) ⇒ Object

Remove all messages matching the block in-place. Generic history pruning utility — used by callers that need to strip transient/system-injected messages out of the persisted history (e.g. compaction, rollback on 400 errors).



81
82
83
84
# File 'lib/clacky/message_history.rb', line 81

def delete_where(&block)
  @messages.reject!(&block)
  self
end

#empty?Boolean

Returns:

  • (Boolean)


253
254
255
# File 'lib/clacky/message_history.rb', line 253

def empty?
  @messages.empty?
end

#estimate_tokensObject

Estimate total token count for all messages. Uses the ~4 chars/token heuristic (works well for English/code). Handles string content, array content blocks, and tool_calls.



260
261
262
# File 'lib/clacky/message_history.rb', line 260

def estimate_tokens
  @messages.sum { |m| estimate_message_tokens(m) }
end

#has_system_prompt?Boolean

True when a system prompt message is present in the history. Used by inject_session_context to avoid injecting context messages before the system prompt has been built (which would cause the guard in run() to skip building it altogether).

Returns:

  • (Boolean)


199
200
201
# File 'lib/clacky/message_history.rb', line 199

def has_system_prompt?
  @messages.any? { |m| m[:role] == "system" }
end

#last_injected_chunk_countObject

Return the chunk_count from the most recently injected chunk index message. Used by inject_chunk_index_if_needed to avoid re-injecting when nothing changed.



224
225
226
227
# File 'lib/clacky/message_history.rb', line 224

def last_injected_chunk_count
  msg = @messages.reverse.find { |m| m[:chunk_index] }
  msg&.dig(:chunk_count) || 0
end

#last_real_user_indexObject

Return the index of the last real (non-system-injected) user message. Used by restore_session to trim back to a clean state on error.



236
237
238
# File 'lib/clacky/message_history.rb', line 236

def last_real_user_index
  @messages.rindex { |m| m[:role] == "user" && !m[:system_injected] }
end

#last_session_context_dateObject

Return the session_date value from the most recent session_context message. Used by inject_session_context_if_needed to avoid re-injecting on the same date.



217
218
219
220
# File 'lib/clacky/message_history.rb', line 217

def last_session_context_date
  msg = @messages.reverse.find { |m| m[:session_context] }
  msg&.dig(:session_date)
end

#mutate_last_matching(predicate, &block) ⇒ Object

Mutate the last message matching the predicate lambda in-place. Used by execute_skill_with_subagent to update instruction messages.



159
160
161
162
163
# File 'lib/clacky/message_history.rb', line 159

def mutate_last_matching(predicate, &block)
  msg = @messages.reverse.find { |m| predicate.call(m) }
  block.call(msg) if msg
  self
end

#pending_tool_calls?Boolean

True when the last assistant message has tool_calls but no tool_result has been appended yet (would cause a 400 from the API).

Returns:

  • (Boolean)


205
206
207
208
209
210
211
212
213
# File 'lib/clacky/message_history.rb', line 205

def pending_tool_calls?
  return false if @messages.empty?

  last = @messages.last
  return false unless last[:role] == "assistant" && last[:tool_calls]&.any?

  last_assistant_idx = @messages.rindex { |m| m == last }
  @messages[(last_assistant_idx + 1)..].none? { |m| m[:role] == "tool" || m[:tool_results] }
end

#pop_lastObject

Remove and return the last message.



73
74
75
# File 'lib/clacky/message_history.rb', line 73

def pop_last
  @messages.pop
end

#real_user_messagesObject

Return only real (non-system-injected) user messages.



230
231
232
# File 'lib/clacky/message_history.rb', line 230

def real_user_messages
  @messages.select { |m| m[:role] == "user" && !m[:system_injected] }
end

#replace_all(new_messages) ⇒ Object

Replace the entire message list (used by compression rebuild).



67
68
69
70
# File 'lib/clacky/message_history.rb', line 67

def replace_all(new_messages)
  @messages = new_messages.map { |m| deep_sanitize_utf8(m) }
  self
end

#replace_system_prompt(content, **extra) ⇒ Object

Replace (or insert at head) the system prompt message. Used by session_serializer#refresh_system_prompt.



55
56
57
58
59
60
61
62
63
64
# File 'lib/clacky/message_history.rb', line 55

def replace_system_prompt(content, **extra)
  msg = { role: "system", content: content }.merge(extra)
  idx = @messages.index { |m| m[:role] == "system" }
  if idx
    @messages[idx] = msg
  else
    @messages.unshift(msg)
  end
  self
end

#rollback_before(message) ⇒ Object

Roll back the history to just before the given message object. Removes the message and anything appended after it. Used to undo a failed speculative append (e.g. compression message that errored).



183
184
185
186
187
188
189
# File 'lib/clacky/message_history.rb', line 183

def rollback_before(message)
  idx = @messages.index { |m| m.equal?(message) }
  return self unless idx

  @messages = @messages[0...idx]
  self
end

#settle_interrupted_tool_calls(transcripts_by_id = {}) ⇒ Object

Settle an interrupted fan-out whose assistant.tool_calls turn is dangling because the batch was cancelled before any tool result was written. Mirrors repair_tool_call_pairing (same helpers, same scan) but PERSISTS the result: for each unanswered tool_call_id it appends a real tool result into the matching one — the same anchor the normal completion path uses. This keeps the turn protocol-valid so drop_dangling_tool_calls! leaves it alone, and lets replay render the transcripts. transcripts_by_id maps tool_call_id => [trail, ...]. No-op unless the last message is a dangling assistant.tool_calls turn.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/clacky/message_history.rb', line 110

def settle_interrupted_tool_calls(transcripts_by_id = {})
  return self unless pending_tool_calls?

  assistant = @messages.last
  expected_ids = Array(assistant[:tool_calls]).map { |tc| tc[:id] }.compact
  answered = []
  @messages.reverse_each do |m|
    break if m.equal?(assistant)

    answered.concat(tool_result_ids(m)) if tool_result_message?(m)
  end

  (expected_ids - answered).each do |id|
    result = {
      role: "tool",
      tool_call_id: id,
      content: '{"interrupted":true,"message":"Interrupted by user before completion"}',
      task_id: assistant[:task_id],
      created_at: Time.now.to_f
    }
    trails = transcripts_by_id[id]
    result[:subagent_transcript] = Array(trails) if trails && !trails.empty?
    @messages << deep_sanitize_utf8(result)
  end
  self
end

#sizeObject

───────────────────────────────────────────── Size helpers ─────────────────────────────────────────────



249
250
251
# File 'lib/clacky/message_history.rb', line 249

def size
  @messages.size
end

#subagent_instruction_messageObject

Return the message with :subagent_instructions set.



241
242
243
# File 'lib/clacky/message_history.rb', line 241

def subagent_instruction_message
  @messages.find { |m| m[:subagent_instructions] }
end

#to_aObject

Return a shallow copy of the message list, excluding transient messages. Transient messages (e.g. brand skill instructions) are valid during the current session but must not be persisted to session.json. For serialization, compression, and cloning.



301
302
303
# File 'lib/clacky/message_history.rb', line 301

def to_a
  @messages.reject { |m| m[:transient] }.dup
end

#to_api(force_reasoning_content_pad: false, task_chain: nil) ⇒ Object

Return a clean copy of messages suitable for sending to the LLM API:

  • strips internal-only fields
  • pads reasoning_content on synthetic assistant messages when the conversation is running against a thinking-mode provider

Convert to API-ready messages. When task_chain is given (a Set of task IDs forming the active task's ancestor chain), messages tagged with a task_id outside that chain are dropped first — this is the Time Machine path, ensuring undone/sibling-branch turns never reach the LLM. Messages without a task_id (system / injected context) are always kept.

Parameters:

  • force_reasoning_content_pad (Boolean) (defaults to: false)

    When true, unconditionally pad every assistant message that lacks a reasoning_content field with an empty string. This is set by the LLM caller AFTER a 400 "reasoning_content must be passed back" error as a one-shot retry signal — the history-evidence heuristic below can't fire when the previous turns came from a provider that keeps thinking inline (e.g. MiniMax: ... in content), so this bypass lets us recover on the retry without a server restart.



286
287
288
289
290
291
292
293
294
295
# File 'lib/clacky/message_history.rb', line 286

def to_api(force_reasoning_content_pad: false, task_chain: nil)
  source = if task_chain
    @messages.select { |m| !m[:task_id] || task_chain.include?(m[:task_id]) }
  else
    @messages
  end
  msgs = source.map { |m| strip_for_api(m) }
  msgs = repair_tool_call_pairing(msgs)
  ensure_reasoning_content_consistency(msgs, force: force_reasoning_content_pad)
end

#truncate_from(index) ⇒ Object

Remove all messages from index onward (used by restore_session on error).



166
167
168
169
# File 'lib/clacky/message_history.rb', line 166

def truncate_from(index)
  @messages = @messages[0...index]
  self
end

#truncate_from_created_at(created_at) ⇒ Object

Truncate history starting from the user message with the given created_at timestamp. Removes that message and everything after it. Returns self.



173
174
175
176
177
178
# File 'lib/clacky/message_history.rb', line 173

def truncate_from_created_at(created_at)
  idx = @messages.index { |m| m[:role] == "user" && m[:created_at].to_s == created_at.to_s }
  return self unless idx

  truncate_from(idx)
end