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 Event 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

Instance Method Summary collapse

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:



169
170
171
172
173
174
175
176
177
178
# File 'app/models/session.rb', line 169

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:



199
200
201
202
203
204
205
206
207
208
# File 'app/models/session.rb', line 199

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: soul first, then environment context, then 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



226
227
228
# File 'app/models/session.rb', line 226

def assemble_system_prompt(environment_context: nil)
  [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.



363
364
365
366
367
368
369
370
371
372
373
374
# File 'app/models/session.rb', line 363

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| {"id" => child.id, "name" => child.name, "processing" => child.processing?} }
  })
end

#create_user_event(content) ⇒ Event

Creates a user message event record directly (bypasses EventBus+Persister). Used by SessionChannel#speak (immediate display), AgentLoop#process, 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:

  • (Event)

    the persisted event record



332
333
334
335
336
337
338
339
# File 'app/models/session.rb', line 332

def create_user_event(content)
  now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
  events.create!(
    event_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



184
185
186
187
188
189
# File 'app/models/session.rb', line 184

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.



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

def deactivate_workflow
  return unless active_workflow.present?

  self.active_workflow = nil
  save!
end

#goals_summaryArray<Hash>

Serializes active goals as a lightweight summary for ActionCable broadcasts and TUI display. Returns a nested structure: root goals with their sub-goals inlined.

Returns:

  • (Array<Hash>)

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



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

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

#heal_orphaned_tool_calls!Integer

Detects orphaned tool_call events (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 event 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



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'app/models/session.rb', line 295

def heal_orphaned_tool_calls!
  now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
  responded_ids = events.where(event_type: "tool_response").where.not(tool_use_id: nil).select(:tool_use_id)
  unresponded = events.where(event_type: "tool_call").where.not(tool_use_id: nil)
    .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 now_ns < deadline_ns

    events.create!(
      event_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: now_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 events] [recalled memories] [sliding window events]

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

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

Parameters:

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

    maximum tokens to include (positive)

Returns:

  • (Array<Hash>)

    Anthropic Messages API format



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/models/session.rb', line 255

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

  events = viewport_events(token_budget: sliding_budget, include_pending: false)

  unless sub_agent?
    first_event_id = events.first&.id
    snapshot_messages = assemble_snapshot_messages(first_event_id, l2_budget: l2_budget, l1_budget: l1_budget)
    pinned_messages = assemble_pinned_event_messages(first_event_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(events))
end

#next_view_modeString

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

Returns:

  • (String)

    the next view mode in the cycle



39
40
41
42
# File 'app/models/session.rb', line 39

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 user messages to delivered status so they appear in the next LLM context. Triggers broadcast_update for each event so connected clients refresh the pending indicator.

Returns:

  • (Integer)

    number of promoted messages



346
347
348
349
350
351
352
353
# File 'app/models/session.rb', line 346

def promote_pending_messages!
  promoted = 0
  events.where(event_type: "user_message", status: Event::PENDING_STATUS).find_each do |event|
    event.update!(status: nil, payload: event.payload.except("status"))
    promoted += 1
  end
  promoted
end

#recalculate_viewport!Array<Integer>

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

Returns:

  • (Array<Integer>)

    IDs of events no longer in the viewport



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

def recalculate_viewport!
  new_ids = viewport_events.map(&:id)
  old_ids = viewport_event_ids

  evicted = old_ids - new_ids
  update_column(:viewport_event_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.



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

def schedule_analytical_brain!
  return if sub_agent?

  count = events.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 event has left the viewport and enqueues MnemeJob when it has. On the first event of a new session, initializes the boundary pointer.

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



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

def schedule_mneme!
  return if sub_agent?

  # Initialize boundary on first conversation event
  if mneme_boundary_event_id.nil?
    first_conversation = events.deliverable
      .where(event_type: Event::CONVERSATION_TYPES)
      .order(:id).first
    first_conversation ||= events.deliverable
      .where(event_type: "tool_call")
      .detect { |e| e.payload["tool_name"] == Event::THINK_TOOL }

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

  # Check if boundary event has left the viewport
  return if viewport_event_ids.include?(mneme_boundary_event_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>)

    event IDs now in the viewport



147
148
149
# File 'app/models/session.rb', line 147

def snapshot_viewport!(ids)
  update_column(:viewport_event_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)



45
46
47
# File 'app/models/session.rb', line 45

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. Main sessions assemble a system prompt from active skills and current 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



158
159
160
# File 'app/models/session.rb', line 158

def system_prompt(environment_context: nil)
  sub_agent? ? prompt : assemble_system_prompt(environment_context: environment_context)
end

#viewport_events(token_budget: Anima::Settings.token_budget, include_pending: true) ⇒ Array<Event>

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

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

Parameters:

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

    maximum tokens to include (positive)

  • include_pending (Boolean) (defaults to: true)

    whether to include pending messages (true for display, false for LLM context assembly)

Returns:

  • (Array<Event>)

    chronologically ordered



113
114
115
116
117
118
119
120
121
122
123
# File 'app/models/session.rb', line 113

def viewport_events(token_budget: Anima::Settings.token_budget, include_pending: true)
  own_events = select_events(own_event_scope(include_pending), budget: token_budget)
  remaining = token_budget - own_events.sum { |e| event_token_cost(e) }

  if sub_agent? && remaining > 0
    parent_events = select_events(parent_event_scope(include_pending), budget: remaining)
    trim_trailing_tool_calls(parent_events) + own_events
  else
    own_events
  end
end