Class: Session

Inherits:
ApplicationRecord show all
Defined in:
app/models/session.rb

Overview

A conversation session — the fundamental unit of agent interaction. Owns an ordered stream of Message records representing everything that happened: user messages, agent responses, tool calls, etc.

Sessions form a hierarchy: a main session can spawn child sessions (sub-agents) that inherit the parent’s viewport context at fork time.

Defined Under Namespace

Classes: MissingSoulError

Constant Summary collapse

VIEW_MODES =
%w[basic verbose debug].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.system_prompt_payload(prompt, tools: nil) ⇒ Hash

Builds the system prompt payload for debug mode transmission. Token estimate covers both the system prompt and tool schemas since both consume the LLM’s context window. Tools are sent as raw schemas; the TUI formats them as TOON for display.

Parameters:

  • prompt (String)

    system prompt text

  • tools (Array<Hash>, nil) (defaults to: nil)

    tool schemas

Returns:

  • (Hash)

    payload with type, rendered debug content, and token estimate



561
562
563
564
565
566
567
568
569
570
571
572
573
574
# File 'app/models/session.rb', line 561

def self.system_prompt_payload(prompt, tools: nil)
  total_bytes = prompt.bytesize
  total_bytes += tools.to_json.bytesize if tools&.any?
  tokens = Message.estimate_token_count(total_bytes)

  debug = {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
  debug[:tools] = tools if tools&.any?

  {
    "id" => Message::SYSTEM_PROMPT_ID,
    "type" => "system_prompt",
    "rendered" => {"debug" => debug}
  }
end

Instance Method Details

#activate_skill(skill_name) ⇒ Skills::Definition

Activates a skill on this session. Validates the skill exists in the registry, updates active_skills, and enqueues the skill content as a PendingMessage so it enters the conversation as a phantom tool_use/tool_result pair through the normal promotion flow.

Parameters:

  • skill_name (String)

    name of the skill to activate

Returns:

Raises:



195
196
197
198
199
200
201
202
203
204
205
# File 'app/models/session.rb', line 195

def activate_skill(skill_name)
  definition = Skills::Registry.instance.find(skill_name)
  raise Skills::InvalidDefinitionError, "Unknown skill: #{skill_name}" unless definition

  return definition if active_skills.include?(skill_name)

  self.active_skills = active_skills + [skill_name]
  save!
  enqueue_recall_message("skill", skill_name, definition.content)
  definition
end

#activate_workflow(workflow_name) ⇒ Workflows::Definition

Activates a workflow on this session. Validates the workflow exists in the registry, sets it as the active workflow, and enqueues the workflow content as a PendingMessage. Only one workflow can be active at a time —activating a new one replaces the previous.

Parameters:

  • workflow_name (String)

    name of the workflow to activate

Returns:

Raises:



228
229
230
231
232
233
234
235
236
237
238
# File 'app/models/session.rb', line 228

def activate_workflow(workflow_name)
  definition = Workflows::Registry.instance.find(workflow_name)
  raise Workflows::InvalidDefinitionError, "Unknown workflow: #{workflow_name}" unless definition

  return definition if active_workflow == workflow_name

  self.active_workflow = workflow_name
  save!
  enqueue_recall_message("workflow", workflow_name, definition.content)
  definition
end

#assemble_system_promptString

Assembles the system prompt: version preamble, soul, and snapshots. Skills, workflows, goals, and environment awareness flow through the message stream and tool responses, keeping the system prompt stable for prompt caching.

Returns:

  • (String)

    composed system prompt



257
258
259
260
# File 'app/models/session.rb', line 257

def assemble_system_prompt
  [assemble_version_preamble, assemble_soul_section, assemble_snapshots_section]
    .compact.join("\n\n")
end

#broadcast_children_update_to_parentvoid

This method returns an undefined value.

Broadcasts child session list to all clients subscribed to the parent session. Called when a child session is created or its processing state changes so the HUD sub-agents section updates in real time.

Queries children via FK directly (avoids loading the parent record) and selects only the columns needed for the HUD payload.



471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'app/models/session.rb', line 471

def broadcast_children_update_to_parent
  return unless parent_session_id

  children = Session.where(parent_session_id: parent_session_id)
    .order(:created_at)
    .select(:id, :name, :processing)
  ActionCable.server.broadcast("session_#{parent_session_id}", {
    "action" => "children_updated",
    "session_id" => parent_session_id,
    "children" => children.map { |child|
      state = child.processing? ? "llm_generating" : "idle"
      {"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
    }
  })
end

#broadcast_debug_context(system:, tools: nil) ⇒ void

This method returns an undefined value.

Broadcasts the full LLM debug context to debug-mode TUI clients. Called on every LLM request so the TUI shows exactly what the LLM receives — system prompt and tool schemas. No-op outside debug mode.

Parameters:

  • system (String, nil)

    the final system prompt sent to the LLM

  • tools (Array<Hash>, nil) (defaults to: nil)

    tool schemas sent to the LLM



521
522
523
524
525
# File 'app/models/session.rb', line 521

def broadcast_debug_context(system:, tools: nil)
  return unless view_mode == "debug" && system

  ActionCable.server.broadcast("session_#{id}", self.class.system_prompt_payload(system, tools: tools))
end

#broadcast_session_state(state, tool: nil) ⇒ void

This method returns an undefined value.

Broadcasts the session’s current processing state to all subscribed clients. Stateless — no storage, pure broadcast. The TUI uses this to drive the braille spinner animation and sub-agent HUD icons.

Payload broadcast to session_{id}:

{"action" => "session_state", "state" => state, "session_id" => id}
# plus "tool" key when state is "tool_executing"

For sub-agents, also broadcasts child_state to the parent stream:

{"action" => "child_state", "state" => state, "session_id" => id, "child_id" => id}

Parameters:

  • state (String)

    one of “idle”, “llm_generating”, “tool_executing”, “interrupting”

  • tool (String, nil) (defaults to: nil)

    tool name when state is “tool_executing”



501
502
503
504
505
506
507
508
509
510
511
512
# File 'app/models/session.rb', line 501

def broadcast_session_state(state, tool: nil)
  payload = {"action" => "session_state", "state" => state, "session_id" => id}
  payload["tool"] = tool if tool
  ActionCable.server.broadcast("session_#{id}", payload)

  # Notify the parent's stream so the HUD updates child state icons
  # without requiring a full children_updated query.
  return unless parent_session_id

  parent_payload = payload.merge("action" => "child_state", "child_id" => id)
  ActionCable.server.broadcast("session_#{parent_session_id}", parent_payload)
end

#create_user_message(content, source_type: nil, source_name: nil) ⇒ Message

Persists a user message directly, bypassing the pending queue.

Used by #enqueue_user_message (idle path), AgentLoop#run, and sub-agent spawn tools (Tools::SpawnSubagent, Tools::SpawnSpecialist) because the global Events::Subscribers::Persister skips non-pending user messages — these callers own the persistence lifecycle.

Parameters:

  • content (String)

    user message text

  • source_type (String, nil) (defaults to: nil)

    origin type (e.g. “skill”, “workflow”) for viewport tracking; omitted for plain user messages

  • source_name (String, nil) (defaults to: nil)

    origin name (e.g. skill name)

Returns:

  • (Message)

    the persisted message record



417
418
419
420
421
422
423
424
425
426
427
# File 'app/models/session.rb', line 417

def create_user_message(content, source_type: nil, source_name: nil)
  now = now_ns
  payload = {type: "user_message", content: content, session_id: id, timestamp: now}
  payload["source_type"] = source_type if source_type
  payload["source_name"] = source_name if source_name
  messages.create!(
    message_type: "user_message",
    payload: payload,
    timestamp: now
  )
end

#deactivate_skill(skill_name) ⇒ void

This method returns an undefined value.

Deactivates a skill on this session. Removes it from active_skills and persists. The skill’s recalled message stays in the conversation and evicts naturally.

Parameters:

  • skill_name (String)

    name of the skill to deactivate



212
213
214
215
216
217
# File 'app/models/session.rb', line 212

def deactivate_skill(skill_name)
  return unless active_skills.include?(skill_name)

  self.active_skills = active_skills - [skill_name]
  save!
end

#deactivate_workflowvoid

This method returns an undefined value.

Deactivates the current workflow on this session. The workflow’s recalled message stays in the conversation and evicts naturally.



244
245
246
247
248
249
# File 'app/models/session.rb', line 244

def deactivate_workflow
  return unless active_workflow.present?

  self.active_workflow = nil
  save!
end

#effective_token_budgetInteger

Token budget appropriate for this session type. Sub-agents use a smaller budget to stay out of the “dumb zone”.

Returns:

  • (Integer)


105
106
107
# File 'app/models/session.rb', line 105

def effective_token_budget
  sub_agent? ? Anima::Settings.subagent_token_budget : Anima::Settings.token_budget
end

#enqueue_user_message(content, source_type: "user", source_name: nil, bounce_back: false) ⇒ void

This method returns an undefined value.

Delivers a user message respecting the session’s processing state.

When idle, persists the message directly and enqueues AgentRequestJob to process it. When mid-turn (#processing?), stages the message as a PendingMessage in a separate table — it gets no message ID until promoted, so it can never interleave with tool_call/tool_response pairs.

Parameters:

  • content (String)

    message text (raw, without attribution)

  • source_type (String) (defaults to: "user")

    origin type: “user” (default) or “subagent”

  • source_name (String, nil) (defaults to: nil)

    sub-agent nickname (required when source_type is “subagent”)

  • bounce_back (Boolean) (defaults to: false)

    when true, passes message_id to the job so failed LLM delivery triggers a Events::BounceBack (used by SessionChannel#speak for immediate-display messages)



359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'app/models/session.rb', line 359

def enqueue_user_message(content, source_type: "user", source_name: nil, bounce_back: false)
  if processing?
    pending_messages.create!(content: content, source_type: source_type, source_name: source_name)
  else
    display = if source_type == "subagent"
      format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
    else
      content
    end
    msg = create_user_message(display)
    job_args = bounce_back ? {message_id: msg.id} : {}
    AgentRequestJob.perform_later(id, **job_args)
  end
end

#goals_summaryArray<Hash>

Serializes non-evicted goals as a lightweight summary for ActionCable broadcasts and TUI display. Returns a nested structure: root goals with their sub-goals inlined. Evicted goals and their sub-goals are excluded.

Returns:

  • (Array<Hash>)

    each with :id, :description, :status, and :sub_goals



268
269
270
# File 'app/models/session.rb', line 268

def goals_summary
  goals.root.not_evicted.includes(:sub_goals).order(:created_at).map(&:as_summary)
end

#heal_orphaned_tool_calls!Integer

Detects orphaned tool_call messages (those without a matching tool_response and whose timeout has expired) and creates synthetic error responses. An orphaned tool_call permanently breaks the session because the Anthropic API rejects conversations where a tool_use block has no matching tool_result.

Respects the per-call timeout stored in the tool_call message payload —a tool_call is only healed after its deadline has passed. This avoids prematurely healing long-running tools that the agent intentionally gave an extended timeout.

Returns:

  • (Integer)

    number of synthetic responses created



316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'app/models/session.rb', line 316

def heal_orphaned_tool_calls!
  current_ns = now_ns
  responded_ids = messages.where(message_type: "tool_response").select(:tool_use_id)
  unresponded = messages.where(message_type: "tool_call")
    .where.not(tool_use_id: responded_ids)

  healed = 0
  unresponded.find_each do |orphan|
    timeout = orphan.payload["timeout"] || Anima::Settings.tool_timeout
    deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
    next if current_ns < deadline_ns

    messages.create!(
      message_type: "tool_response",
      payload: {
        "type" => "tool_response",
        "content" => "Tool execution timed out after #{timeout} seconds — no result was returned.",
        "tool_name" => orphan.payload["tool_name"],
        "tool_use_id" => orphan.tool_use_id,
        "success" => false
      },
      tool_use_id: orphan.tool_use_id,
      timestamp: current_ns
    )
    healed += 1
  end
  healed
end

#messages_for_llm(token_budget: effective_token_budget) ⇒ Array<Hash>

Builds the message array expected by the Anthropic Messages API. Viewport layout (top to bottom):

[context prefix: goals + pinned messages] [sliding window messages]

Snapshots live in the system prompt (stable between Mneme runs). Goal events and recalled memories flow through the message stream as phantom tool pairs — they ride the conveyor belt as regular messages. After eviction, a goal snapshot + pinned messages block is rebuilt from DB state and prepended as a phantom pair.

The sliding window is post-processed by #ensure_atomic_tool_pairs which removes orphaned tool messages whose partner was cut off by the token budget.

Parameters:

  • token_budget (Integer) (defaults to: effective_token_budget)

    maximum tokens to include (positive)

Returns:

  • (Array<Hash>)

    Anthropic Messages API format



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'app/models/session.rb', line 288

def messages_for_llm(token_budget: effective_token_budget)
  heal_orphaned_tool_calls!

  sliding_budget = token_budget

  pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
  sliding_budget -= pinned_budget

  window = viewport_messages(token_budget: sliding_budget)
  first_message_id = window.first&.id

  prefix = assemble_context_prefix_messages(first_message_id, budget: pinned_budget)

  prefix + assemble_messages(ensure_atomic_tool_pairs(window))
end

#next_view_modeString

Cycles to the next view mode: basic → verbose → debug → basic.

Returns:

  • (String)

    the next view mode in the cycle



41
42
43
44
# File 'app/models/session.rb', line 41

def next_view_mode
  current_index = VIEW_MODES.index(view_mode) || 0
  VIEW_MODES[(current_index + 1) % VIEW_MODES.size]
end

#promote_pending_messages!Hash{Symbol => Array}

Promotes all pending messages into the conversation history. Each PendingMessage is atomically deleted and replaced with a real Message — the new message gets the next auto-increment ID, naturally placing it after any tool_call/tool_response pairs that were persisted while the message was waiting.

Returns a hash with two keys:

  • :texts — plain content strings for user messages (injected as text blocks within the current tool_results turn)

  • :pairs — synthetic tool_use/tool_result message hashes for phantom pair types (appended as new conversation turns)

Returns:

  • (Hash{Symbol => Array})

    promoted messages split by injection strategy



442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
# File 'app/models/session.rb', line 442

def promote_pending_messages!
  texts = []
  pairs = []
  pending_messages.find_each do |pm|
    transaction do
      if pm.phantom_pair?
        promote_phantom_pair!(pm)
      else
        create_user_message(pm.display_content, source_type: pm.source_type, source_name: pm.source_name)
      end
      pm.destroy!
    end
    if pm.phantom_pair?
      pairs.concat(pm.to_llm_messages)
    else
      texts << pm.content
    end
  end
  {texts: texts, pairs: pairs}
end

#promote_phantom_pair!(pm) ⇒ void

This method returns an undefined value.

Promotes a phantom pair pending message into a tool_call/tool_response pair. These persist as real Message records and ride the conveyor belt.

Parameters:



379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'app/models/session.rb', line 379

def promote_phantom_pair!(pm)
  tool_name = pm.phantom_tool_name
  tool_input = pm.phantom_tool_input
  uid = "#{tool_name}_#{pm.id}"
  now = now_ns

  messages.create!(
    message_type: "tool_call",
    tool_use_id: uid,
    payload: {"tool_name" => tool_name, "tool_use_id" => uid,
              "tool_input" => tool_input.stringify_keys,
              "content" => pm.display_content.lines.first.chomp},
    timestamp: now,
    token_count: Mneme::PassiveRecall::TOOL_PAIR_OVERHEAD_TOKENS
  )

  messages.create!(
    message_type: "tool_response",
    tool_use_id: uid,
    payload: {"tool_name" => tool_name, "tool_use_id" => uid,
              "content" => pm.content, "success" => true},
    timestamp: now,
    token_count: Message.estimate_token_count(pm.content.bytesize)
  )
end

#recalculate_viewport!Array<Integer>

Recalculates the viewport and returns IDs of messages evicted since the last snapshot. Updates the stored viewport_message_ids atomically. Piggybacks on message broadcasts to notify clients which messages left the LLM’s context window.

Returns:

  • (Array<Integer>)

    IDs of messages no longer in the viewport



129
130
131
132
133
134
135
136
# File 'app/models/session.rb', line 129

def recalculate_viewport!
  new_ids = viewport_messages.map(&:id)
  old_ids = viewport_message_ids

  evicted = old_ids - new_ids
  update_column(:viewport_message_ids, new_ids) if old_ids != new_ids
  evicted
end

#schedule_analytical_brain!void

This method returns an undefined value.

Enqueues the analytical brain to perform background maintenance on this session. Currently handles session naming; future phases add skill activation, goal tracking, and memory.

Runs after the first exchange and periodically as the conversation evolves, so the name stays relevant to the current topic.



91
92
93
94
95
96
97
98
99
100
# File 'app/models/session.rb', line 91

def schedule_analytical_brain!
  return if sub_agent?

  count = messages.llm_messages.count
  return if count < 2
  # Already named — only regenerate at interval boundaries (30, 60, 90, …)
  return if name.present? && (count % Anima::Settings.name_generation_interval != 0)

  AnalyticalBrainJob.perform_later(id)
end

#schedule_mneme!void

This method returns an undefined value.

Checks whether the Mneme terminal message has left the viewport and enqueues MnemeJob when it has. On the first message of a new session, initializes the boundary pointer.

The terminal message is always a conversation message (user/agent message or think tool_call), never a bare tool_call/tool_response.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'app/models/session.rb', line 59

def schedule_mneme!
  return if sub_agent?

  # Initialize boundary on first conversation message
  if mneme_boundary_message_id.nil?
    first_conversation = messages
      .where(message_type: Message::CONVERSATION_TYPES)
      .order(:id).first
    first_conversation ||= messages
      .where(message_type: "tool_call")
      .detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }

    if first_conversation
      update_column(:mneme_boundary_message_id, first_conversation.id)
    end
    return
  end

  # Check if boundary message has left the viewport
  return if viewport_message_ids.include?(mneme_boundary_message_id)

  MnemeJob.perform_later(id)
end

#skills_in_viewportSet<String>

Returns skill names whose recalled content is currently visible in the viewport. Used by the analytical brain for deduplication — skills already in the viewport are excluded from the activation catalog.

Returns:

  • (Set<String>)

    skill names present in the viewport



154
155
156
# File 'app/models/session.rb', line 154

def skills_in_viewport
  recalled_sources_in_viewport("skill")
end

#snapshot_viewport!(ids) ⇒ void

This method returns an undefined value.

Overwrites the viewport snapshot without computing evictions. Used when transmitting or broadcasting a full viewport refresh, where eviction notifications are unnecessary (clients clear their store first).

Parameters:

  • ids (Array<Integer>)

    message IDs now in the viewport



145
146
147
# File 'app/models/session.rb', line 145

def snapshot_viewport!(ids)
  update_column(:viewport_message_ids, ids)
end

#sub_agent?Boolean

Returns true if this session is a sub-agent (has a parent).

Returns:

  • (Boolean)

    true if this session is a sub-agent (has a parent)



47
48
49
# File 'app/models/session.rb', line 47

def sub_agent?
  parent_session_id.present?
end

#system_promptString?

Returns the system prompt for this session. Sub-agent sessions use their stored prompt plus active skills and the pinned task. Main sessions assemble a full system prompt from soul and snapshots. Skills, workflows, and goals are injected as phantom tool_use/tool_result pairs in the message stream (not here) to keep the system prompt stable for prompt caching. Environment awareness flows through Bash tool responses.

Sub-agent sessions still include expertise inline — they’re short-lived and don’t benefit from prompt caching.

Returns:

  • (String, nil)

    the system prompt text, or nil when nothing to inject



178
179
180
181
182
183
184
# File 'app/models/session.rb', line 178

def system_prompt
  if sub_agent?
    [prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n")
  else
    assemble_system_prompt
  end
end

#tool_schemasArray<Hash>

Returns the deterministic tool schemas for this session’s type and granted_tools configuration. Standard and spawn tools are static class-level definitions — no ShellSession or registry needed. MCP tools are excluded (they require live server queries and appear after the first LLM request via #broadcast_debug_context).

Returns:

  • (Array<Hash>)

    tool schema hashes matching Anthropic tools API format



534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
# File 'app/models/session.rb', line 534

def tool_schemas
  tools = if granted_tools
    granted = granted_tools.filter_map { |name| AgentLoop::STANDARD_TOOLS_BY_NAME[name] }
    (AgentLoop::ALWAYS_GRANTED_TOOLS + granted).uniq
  else
    AgentLoop::STANDARD_TOOLS.dup
  end

  unless sub_agent?
    tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
  end

  if sub_agent?
    tools.push(Tools::MarkGoalCompleted)
  end

  tools.map(&:schema)
end

#viewport_messages(token_budget: effective_token_budget) ⇒ Array<Message>

Returns the messages currently visible in the LLM context window. Walks messages newest-first and includes them until the token budget is exhausted. Messages are full-size or excluded entirely.

Pending messages live in a separate table (PendingMessage) and never appear in this viewport — they are promoted to real messages before the agent processes them.

Parameters:

  • token_budget (Integer) (defaults to: effective_token_budget)

    maximum tokens to include (positive)

Returns:

  • (Array<Message>)

    chronologically ordered



119
120
121
# File 'app/models/session.rb', line 119

def viewport_messages(token_budget: effective_token_budget)
  select_messages(own_message_scope, budget: token_budget)
end

#workflow_in_viewportString?

Returns the workflow name currently visible in the viewport, if any. Only one workflow can be active at a time, so we return the first match.

Returns:

  • (String, nil)

    workflow name present in the viewport



162
163
164
# File 'app/models/session.rb', line 162

def workflow_in_viewport
  recalled_sources_in_viewport("workflow").first
end