Class: Clacky::MessageHistory
- Inherits:
-
Object
- Object
- Clacky::MessageHistory
- 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
-
.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).
Instance Method Summary collapse
-
#append(message) ⇒ Object
Append a single message hash to the history.
-
#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.
-
#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.
-
#delete_where(&block) ⇒ Object
Remove all messages matching the block in-place.
- #empty? ⇒ Boolean
-
#estimate_tokens ⇒ Object
Estimate total token count for all messages.
-
#has_system_prompt? ⇒ Boolean
True when a system prompt message is present in the history.
-
#initialize(messages = []) ⇒ MessageHistory
constructor
A new instance of MessageHistory.
-
#last_injected_chunk_count ⇒ Object
Return the chunk_count from the most recently injected chunk index message.
-
#last_real_user_index ⇒ Object
Return the index of the last real (non-system-injected) user message.
-
#last_session_context_date ⇒ Object
Return the session_date value from the most recent session_context message.
-
#mutate_last_matching(predicate, &block) ⇒ Object
Mutate the last message matching the predicate lambda in-place.
-
#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).
-
#pop_last ⇒ Object
Remove and return the last message.
-
#real_user_messages ⇒ Object
Return only real (non-system-injected) user messages.
-
#replace_all(new_messages) ⇒ Object
Replace the entire message list (used by compression rebuild).
-
#replace_system_prompt(content, **extra) ⇒ Object
Replace (or insert at head) the system prompt message.
-
#rollback_before(message) ⇒ Object
Roll back the history to just before the given message object.
-
#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.
-
#size ⇒ Object
───────────────────────────────────────────── Size helpers ─────────────────────────────────────────────.
-
#subagent_instruction_message ⇒ Object
Return the message with :subagent_instructions set.
-
#to_a ⇒ Object
Return a shallow copy of the message list, excluding transient messages.
-
#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.
-
#truncate_from(index) ⇒ Object
Remove all messages from index onward (used by restore_session on error).
-
#truncate_from_created_at(created_at) ⇒ Object
Truncate history starting from the user message with the given created_at timestamp.
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 = .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() if [:role] == "user" drop_dangling_tool_calls! end @messages << deep_sanitize_utf8() 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
253 254 255 |
# File 'lib/clacky/message_history.rb', line 253 def empty? @messages.empty? end |
#estimate_tokens ⇒ Object
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| (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).
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_count ⇒ Object
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_index ⇒ Object
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_date ⇒ Object
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).
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_last ⇒ Object
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_messages ⇒ Object
Return only real (non-system-injected) user messages.
230 231 232 |
# File 'lib/clacky/message_history.rb', line 230 def @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() @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() idx = @messages.index { |m| m.equal?() } 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 (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 |
#size ⇒ Object
───────────────────────────────────────────── Size helpers ─────────────────────────────────────────────
249 250 251 |
# File 'lib/clacky/message_history.rb', line 249 def size @messages.size end |
#subagent_instruction_message ⇒ Object
Return the message with :subagent_instructions set.
241 242 243 |
# File 'lib/clacky/message_history.rb', line 241 def @messages.find { |m| m[:subagent_instructions] } end |
#to_a ⇒ Object
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.
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 |