Class: PendingMessage
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- PendingMessage
- Defined in:
- app/models/pending_message.rb
Overview
A message waiting to enter a session’s conversation history. Pending messages live in their own table — they are NOT part of the message stream and have no database ID that could interleave with tool_call/tool_response pairs.
Entry point of the event-driven drain pipeline. Every inbound message destined for the LLM — user input, tool responses, sub-agent replies, Mneme recalls, Melete skills/goals — lands here first, then gets promoted into a real Message by DrainJob.
Each pending message knows its source (source_type, source_name) and how to serialize itself for the LLM conversation via #to_llm_messages. Non-user messages (sub-agent results, recalled skills, workflows, recall, goal events) become synthetic tool_use/tool_result pairs so the LLM sees “a tool I invoked returned a result” rather than “a user wrote me.”
Classifies itself for the pipeline via kind (active triggers the drain loop, background enriches context silently) and message_type (selects which pipeline event to emit on create).
Constant Summary collapse
- MELETE_SKILL_TOOL =
Phantom tool names follow the ‘from_<sender>` convention: the prefix tells the LLM these are messages delivered to it by its sisters or sub-agents, not tools it invoked. Melete’s contributions carry the type in the suffix so the viewport query can filter by kind.
"from_melete_skill"- MELETE_WORKFLOW_TOOL =
"from_melete_workflow"- MELETE_GOAL_TOOL =
"from_melete_goal"- MNEME_TOOL =
"from_mneme"- PHANTOM_PAIR_TYPES =
Source types that produce phantom tool_use/tool_result pairs on promotion. User messages produce plain text blocks instead.
%w[subagent skill workflow recall goal].freeze
- PHANTOM_TOOL_NAMES =
Maps each phantom pair source type to a lambda that builds its synthetic tool name. Each Melete contribution carries the type in its suffix; recalled memories come from Mneme; sub-agents encode their nickname directly (e.g. ‘from_sleuth`).
{ "subagent" => ->(name) { "from_#{name}" }, "skill" => ->(_) { MELETE_SKILL_TOOL }, "workflow" => ->(_) { MELETE_WORKFLOW_TOOL }, "recall" => ->(_) { MNEME_TOOL }, "goal" => ->(_) { MELETE_GOAL_TOOL } }.freeze
- PHANTOM_TOOL_INPUTS =
Maps each phantom pair source type to a lambda building its tool input.
{ "subagent" => ->(name) { {from: name} }, "skill" => ->(name) { {skill: name} }, "workflow" => ->(name) { {workflow: name} }, "recall" => ->(name) { {message_id: name.to_i} }, "goal" => ->(name) { {goal_id: name.to_i} } }.freeze
- MESSAGE_TYPE_KINDS =
Every message_type has a defined drain-pipeline role.
activetypes trigger the drain loop when the session is idle;backgroundtypes enrich context silently and ride the next active turn into the LLM. #kind is derived from this map in #derive_kind — callers only supplymessage_type. { "user_message" => "active", "tool_response" => "active", "subagent" => "active", "from_mneme" => "background", "from_melete_skill" => "background", "from_melete_workflow" => "background", "from_melete_goal" => "background" }.freeze
- MESSAGE_TYPES =
MESSAGE_TYPE_KINDS.keys.freeze
- MESSAGE_TYPE_ROUTES =
Routes active message types to the event that begins the drain pipeline. User messages enter through Melete (skill/workflow/goal preparation); Mneme then runs conditionally only when Melete actually mutates goals (set_goal / update_goal), so recall always fires against fresh goals. Tool responses and sub-agent deliveries bypass enrichment and go straight to the drain loop. Background message types route to nothing — they wait in the mailbox until an active turn drains them.
{ "user_message" => Events::StartMelete, "tool_response" => Events::StartProcessing, "subagent" => Events::StartProcessing }.freeze
Instance Attribute Summary collapse
-
#promoted_message_id ⇒ Object
In-memory id of the
Messagethis PM becomes on #promote!.
Instance Method Summary collapse
-
#broadcast_payload(mode) ⇒ Hash
Builds the structured
pending_message_createdpayload for transmit/ broadcast paths. -
#decorator_class ⇒ Class
Draper hook: picks the concrete decorator subclass based on #message_type.
-
#display_content ⇒ String
Content formatted for display and history persistence.
-
#goal? ⇒ Boolean
True when this message carries a goal event.
-
#phantom_pair? ⇒ Boolean
True when promotion produces phantom tool_use/tool_result pairs.
-
#phantom_tool_input ⇒ Hash
Phantom tool input hash for DB persistence and LLM injection.
-
#phantom_tool_name ⇒ String
Phantom tool name for DB persistence and LLM injection.
-
#promote! ⇒ void
Promotes this PendingMessage into the session’s conversation history.
-
#recall? ⇒ Boolean
True when this message is an associative recall phantom pair.
-
#route_to_event_bus ⇒ Object
Emits the event that kicks off the drain pipeline for active messages whenever the session is currently claimable.
-
#skill? ⇒ Boolean
True when this message carries recalled skill content.
-
#subagent? ⇒ Boolean
True when this message originated from a sub-agent.
-
#to_llm_messages ⇒ Array<Hash>, String
Builds LLM message hashes for this pending message.
-
#user? ⇒ Boolean
True when this is a plain user message.
-
#workflow? ⇒ Boolean
True when this message carries recalled workflow content.
Instance Attribute Details
#promoted_message_id ⇒ Object
In-memory id of the Message this PM becomes on #promote!. Not persisted — the PM row is destroyed as part of the promotion transaction. Used by Session#release_with_bounce_back to destroy the exact message that should bounce, instead of guessing from messages.last (which could race under parallel drains).
99 100 101 |
# File 'app/models/pending_message.rb', line 99 def @promoted_message_id end |
Instance Method Details
#broadcast_payload(mode) ⇒ Hash
Builds the structured pending_message_created payload for transmit/ broadcast paths. Wraps the per-mode decorator output in the rendered key so the TUI’s existing extract_rendered pipeline applies.
Required arg — callers always know the session view_mode. A default of session.view_mode would trigger a SELECT per after_create_commit when the association isn’t preloaded.
The raw content field is intentionally absent: decorators decide what crosses the wire per view_mode (e.g. background PMs return nil in basic so the user doesn’t see internal pipeline noise). Sending raw content alongside rendered would undercut that boundary.
301 302 303 304 305 306 307 308 |
# File 'app/models/pending_message.rb', line 301 def broadcast_payload(mode) { "action" => "pending_message_created", "pending_message_id" => id, "message_type" => , "rendered" => {mode => decorate.render(mode)} } end |
#decorator_class ⇒ Class
Draper hook: picks the concrete decorator subclass based on #message_type. Mirrors Message#decorator_class so each PM type renders with the same visual treatment as its promoted counterpart, marked dimmed via status: “pending”.
PMs are the universal intake queue — every new message_type added under #427 lands here first. Raises on unmapped types so a missing decorator surfaces immediately as a hard failure instead of a silent nil that breaks downstream rendering.
170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'app/models/pending_message.rb', line 170 def decorator_class case when "user_message" then PendingUserMessageDecorator when "tool_response" then PendingToolResponseDecorator when "subagent" then PendingSubagentDecorator when "from_mneme" then PendingFromMnemeDecorator when "from_melete_skill" then PendingFromMeleteSkillDecorator when "from_melete_workflow" then PendingFromMeleteWorkflowDecorator when "from_melete_goal" then PendingFromMeleteGoalDecorator else raise ArgumentError, "No decorator for PendingMessage message_type: #{.inspect}" end end |
#display_content ⇒ String
Content formatted for display and history persistence. Sub-agent messages include an attribution prefix. Skill/workflow messages include a recall label. User messages pass through unchanged.
235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
# File 'app/models/pending_message.rb', line 235 def display_content case source_type when "subagent" format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content) when "skill" "[recalled skill: #{source_name}]\n#{content}" when "workflow" "[recalled workflow: #{source_name}]\n#{content}" when "goal" "[goal #{source_name}]\n#{content}" else content end end |
#goal? ⇒ Boolean
Returns true when this message carries a goal event.
149 150 151 |
# File 'app/models/pending_message.rb', line 149 def goal? source_type == "goal" end |
#phantom_pair? ⇒ Boolean
Returns true when promotion produces phantom tool_use/tool_result pairs.
154 155 156 |
# File 'app/models/pending_message.rb', line 154 def phantom_pair? source_type.in?(PHANTOM_PAIR_TYPES) end |
#phantom_tool_input ⇒ Hash
Phantom tool input hash for DB persistence and LLM injection.
226 227 228 |
# File 'app/models/pending_message.rb', line 226 def phantom_tool_input PHANTOM_TOOL_INPUTS.fetch(source_type).call(source_name) end |
#phantom_tool_name ⇒ String
Phantom tool name for DB persistence and LLM injection. Each phantom pair source type maps to a synthetic tool name via PHANTOM_TOOL_NAMES — a lambda so sub-agent names can flow through.
219 220 221 |
# File 'app/models/pending_message.rb', line 219 def phantom_tool_name PHANTOM_TOOL_NAMES.fetch(source_type).call(source_name) end |
#promote! ⇒ void
This method returns an undefined value.
Promotes this PendingMessage into the session’s conversation history. Dispatches on message_type: tool responses become tool_response Messages, user messages become user_message Messages, phantom pair types (sub-agent, skill, workflow, recall, goal) become synthetic tool_use/tool_result pairs. The PM row is destroyed in the same transaction so partial promotion can never leave a stray mailbox entry.
For promotions that yield a single Message, #promoted_message_id captures the new record’s id — callers can then act on that specific message (e.g. Session#release_with_bounce_back) without guessing.
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'app/models/pending_message.rb', line 195 def promote! session.transaction do if == "tool_response" self. = promote_as_tool_response!.id elsif == "user_message" self. = session.( display_content, source_type: source_type, source_name: source_name ).id elsif phantom_pair? promote_as_phantom_pair! else raise "PendingMessage ##{id} cannot promote: message_type=#{.inspect}" end destroy! end end |
#recall? ⇒ Boolean
Returns true when this message is an associative recall phantom pair.
144 145 146 |
# File 'app/models/pending_message.rb', line 144 def recall? source_type == "recall" end |
#route_to_event_bus ⇒ Object
Emits the event that kicks off the drain pipeline for active messages whenever the session is currently claimable. Claimability is delegated to the AASM via may_start_processing? — true from :idle always, true from :executing only once tool_round_complete? holds. This lets a tool_response PM landing mid-round wake the drain only when its sibling responses are all present.
Background messages never trigger; active messages landing while the session is unclaimable queue silently —Session#wake_drain_pipeline_if_pending re-invokes this on the next transition into :idle.
Also fires from after_create_commit so freshly enqueued PMs route themselves on persistence.
278 279 280 281 282 283 284 |
# File 'app/models/pending_message.rb', line 278 def route_to_event_bus return unless active? return unless session.may_start_processing? event_class = MESSAGE_TYPE_ROUTES.fetch() Events::Bus.emit(event_class.new(session_id: session_id, pending_message_id: id)) end |
#skill? ⇒ Boolean
Returns true when this message carries recalled skill content.
134 135 136 |
# File 'app/models/pending_message.rb', line 134 def skill? source_type == "skill" end |
#subagent? ⇒ Boolean
Returns true when this message originated from a sub-agent.
129 130 131 |
# File 'app/models/pending_message.rb', line 129 def subagent? source_type == "subagent" end |
#to_llm_messages ⇒ Array<Hash>, String
Builds LLM message hashes for this pending message.
Phantom pair types become synthetic tool_use/tool_result pairs so the LLM sees them as its own past invocations. User messages return plain content for injection as text blocks within the current tool_results turn.
258 259 260 261 262 |
# File 'app/models/pending_message.rb', line 258 def return content unless phantom_pair? build_phantom_pair(phantom_tool_name, phantom_tool_input) end |
#user? ⇒ Boolean
Returns true when this is a plain user message.
124 125 126 |
# File 'app/models/pending_message.rb', line 124 def user? source_type == "user" end |
#workflow? ⇒ Boolean
Returns true when this message carries recalled workflow content.
139 140 141 |
# File 'app/models/pending_message.rb', line 139 def workflow? source_type == "workflow" end |