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:



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

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:



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

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



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

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.



318
319
320
321
322
323
324
325
326
327
328
329
# File 'app/models/session.rb', line 318

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 AgentRequestJob (Bounce Back transaction), 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



287
288
289
290
291
292
293
294
# File 'app/models/session.rb', line 287

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



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

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.



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

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



233
234
235
# File 'app/models/session.rb', line 233

def goals_summary
  goals.root.includes(:sub_goals).order(:created_at).map(&:as_summary)
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



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

def messages_for_llm(token_budget: Anima::Settings.token_budget)
  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(events)
end

#next_view_modeString

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

Returns:

  • (String)

    the next view mode in the cycle



37
38
39
40
# File 'app/models/session.rb', line 37

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



301
302
303
304
305
306
307
308
# File 'app/models/session.rb', line 301

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



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

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.



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

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.



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

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



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

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)



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

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



156
157
158
# File 'app/models/session.rb', line 156

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



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

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