Class: PendingMessage

Inherits:
ApplicationRecord show all
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. active types trigger the drain loop when the session is idle; background types enrich context silently and ride the next active turn into the LLM. #kind is derived from this map in #derive_kind — callers only supply message_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

Instance Method Summary collapse

Instance Attribute Details

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
  @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.

Parameters:

  • mode (String)

    view mode for decoration

Returns:

  • (Hash)

    payload ready for ActionCable transmission



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" => message_type,
    "rendered" => {mode => decorate.render(mode)}
  }
end

#decorator_classClass

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.

Returns:

Raises:

  • (ArgumentError)

    if no decorator is registered for the message_type



170
171
172
173
174
175
176
177
178
179
180
181
# File 'app/models/pending_message.rb', line 170

def decorator_class
  case message_type
  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: #{message_type.inspect}"
  end
end

#display_contentString

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.

Returns:

  • (String)


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.

Returns:

  • (Boolean)

    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.

Returns:

  • (Boolean)

    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_inputHash

Phantom tool input hash for DB persistence and LLM injection.

Returns:

  • (Hash)

    tool input hash



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_nameString

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.

Returns:

  • (String)

    phantom tool name



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 message_type == "tool_response"
      self.promoted_message_id = promote_as_tool_response!.id
    elsif message_type == "user_message"
      self.promoted_message_id = session.create_user_message(
        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=#{message_type.inspect}"
    end
    destroy!
  end
end

#recall?Boolean

Returns true when this message is an associative recall phantom pair.

Returns:

  • (Boolean)

    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_busObject

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(message_type)
  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.

Returns:

  • (Boolean)

    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.

Returns:

  • (Boolean)

    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_messagesArray<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.

Returns:

  • (Array<Hash>)

    synthetic tool pair for phantom pair types

  • (String)

    raw content for user messages



258
259
260
261
262
# File 'app/models/pending_message.rb', line 258

def to_llm_messages
  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.

Returns:

  • (Boolean)

    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.

Returns:

  • (Boolean)

    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