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



495
496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'app/models/session.rb', line 495

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, adds it to active_skills, and persists.

Parameters:

  • skill_name (String)

    name of the skill to activate

Returns:

Raises:



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

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!
  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 persists. 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:



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

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!
  definition
end

#assemble_system_prompt(environment_context: nil) ⇒ String

Assembles the system prompt: version preamble, soul, environment context, skills/workflow, then goals. The soul is always present — “who am I” before “what can I do.”

Parameters:

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

    pre-assembled environment block

Returns:

  • (String)

    composed system prompt



235
236
237
# File 'app/models/session.rb', line 235

def assemble_system_prompt(environment_context: nil)
  [assemble_version_preamble, assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_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.



405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
# File 'app/models/session.rb', line 405

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



455
456
457
458
459
# File 'app/models/session.rb', line 455

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”



435
436
437
438
439
440
441
442
443
444
445
446
# File 'app/models/session.rb', line 435

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) ⇒ 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

Returns:

  • (Message)

    the persisted message record



369
370
371
372
373
374
375
376
# File 'app/models/session.rb', line 369

def create_user_message(content)
  now = now_ns
  messages.create!(
    message_type: "user_message",
    payload: {type: "user_message", content: content, session_id: id, timestamp: now},
    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.

Parameters:

  • skill_name (String)

    name of the skill to deactivate



193
194
195
196
197
198
# File 'app/models/session.rb', line 193

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.



222
223
224
225
226
227
# File 'app/models/session.rb', line 222

def deactivate_workflow
  return unless active_workflow.present?

  self.active_workflow = nil
  save!
end

#enqueue_user_message(content, 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)

    user message text

  • 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)



350
351
352
353
354
355
356
357
358
# File 'app/models/session.rb', line 350

def enqueue_user_message(content, bounce_back: false)
  if processing?
    pending_messages.create!(content: content)
  else
    msg = create_user_message(content)
    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



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

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



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'app/models/session.rb', line 309

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: Anima::Settings.token_budget) ⇒ Array<Hash>

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

[L2 snapshots] [L1 snapshots] [pinned messages] [recalled memories] [sliding window messages]

Snapshots appear ONLY after their source messages have evicted from the sliding window. L1 snapshots drop once covered by an L2 snapshot. Pinned messages are critical context attached to active Goals — they survive eviction intact until their Goals complete. Recalled memories surface relevant older messages (passive recall via goals). Each layer has a fixed token budget fraction — snapshots, pins, and recall consume viewport space, reducing the sliding window size.

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.

Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent messages directly).

Parameters:

  • token_budget (Integer) (defaults to: Anima::Settings.token_budget)

    maximum tokens to include (positive)

Returns:

  • (Array<Hash>)

    Anthropic Messages API format



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'app/models/session.rb', line 269

def messages_for_llm(token_budget: Anima::Settings.token_budget)
  heal_orphaned_tool_calls!

  sliding_budget = token_budget
  snapshot_messages = []
  pinned_messages = []
  recall_messages = []

  unless sub_agent?
    l2_budget = (token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
    l1_budget = (token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
    pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
    recall_budget = (token_budget * Anima::Settings.recall_budget_fraction).to_i
    sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
  end

  window = viewport_messages(token_budget: sliding_budget)

  unless sub_agent?
    first_message_id = window.first&.id
    snapshot_messages = assemble_snapshot_messages(first_message_id, l2_budget: l2_budget, l1_budget: l1_budget)
    pinned_messages = assemble_pinned_section_messages(first_message_id, budget: pinned_budget)
    recall_messages = assemble_recall_messages(budget: recall_budget)
  end

  snapshot_messages + pinned_messages + recall_messages + 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!Integer

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:

  • (Integer)

    number of promoted messages



385
386
387
388
389
390
391
392
393
394
395
# File 'app/models/session.rb', line 385

def promote_pending_messages!
  promoted = 0
  pending_messages.find_each do |pm|
    transaction do
      create_user_message(pm.content)
      pm.destroy!
    end
    promoted += 1
  end
  promoted
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



135
136
137
138
139
140
141
142
# File 'app/models/session.rb', line 135

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

#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



151
152
153
# File 'app/models/session.rb', line 151

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_prompt(environment_context: nil) ⇒ String?

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, environment, skills/workflow, and goals.

Parameters:

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

    pre-assembled environment block from EnvironmentProbe; injected between soul and expertise sections

Returns:

  • (String, nil)

    the system prompt text, or nil when nothing to inject



163
164
165
166
167
168
169
# File 'app/models/session.rb', line 163

def system_prompt(environment_context: nil)
  if sub_agent?
    [prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n")
  else
    assemble_system_prompt(environment_context: environment_context)
  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



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

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: Anima::Settings.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.

Sub-agent sessions inherit parent context via virtual viewport: child messages are prioritized and fill the budget first (newest-first), then parent messages from before the fork point fill the remaining budget. The final array is chronological: parent messages first, then child messages.

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: Anima::Settings.token_budget)

    maximum tokens to include (positive)

Returns:

  • (Array<Message>)

    chronologically ordered



117
118
119
120
121
122
123
124
125
126
127
# File 'app/models/session.rb', line 117

def viewport_messages(token_budget: Anima::Settings.token_budget)
  own = select_messages(own_message_scope, budget: token_budget)
  remaining = token_budget - own.sum { |msg| message_token_cost(msg) }

  if sub_agent? && remaining > 0
    parent = select_messages(parent_message_scope, budget: remaining)
    trim_trailing_tool_calls(parent) + own
  else
    own
  end
end