Class: Session
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Session
- 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
-
#activate_skill(skill_name) ⇒ Skills::Definition
Activates a skill on this session.
-
#activate_workflow(workflow_name) ⇒ Workflows::Definition
Activates a workflow on this session.
-
#assemble_system_prompt(environment_context: nil) ⇒ String
Assembles the system prompt: soul first, then environment context, then skills/workflow, then goals.
-
#broadcast_children_update_to_parent ⇒ void
Broadcasts child session list to all clients subscribed to the parent session.
-
#create_user_event(content) ⇒ Event
Creates a user message event record directly (bypasses EventBus+Persister).
-
#deactivate_skill(skill_name) ⇒ void
Deactivates a skill on this session.
-
#deactivate_workflow ⇒ void
Deactivates the current workflow on this session.
-
#goals_summary ⇒ Array<Hash>
Serializes active goals as a lightweight summary for ActionCable broadcasts and TUI display.
-
#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.
-
#messages_for_llm(token_budget: Anima::Settings.token_budget) ⇒ Array<Hash>
Builds the message array expected by the Anthropic Messages API.
-
#next_view_mode ⇒ String
Cycles to the next view mode: basic → verbose → debug → basic.
-
#promote_pending_messages! ⇒ Integer
Promotes all pending user messages to delivered status so they appear in the next LLM context.
-
#recalculate_viewport! ⇒ Array<Integer>
Recalculates the viewport and returns IDs of events evicted since the last snapshot.
-
#schedule_analytical_brain! ⇒ void
Enqueues the analytical brain to perform background maintenance on this session.
-
#schedule_mneme! ⇒ void
Checks whether the Mneme terminal event has left the viewport and enqueues MnemeJob when it has.
-
#snapshot_viewport!(ids) ⇒ void
Overwrites the viewport snapshot without computing evictions.
-
#sub_agent? ⇒ Boolean
True if this session is a sub-agent (has a parent).
-
#system_prompt(environment_context: nil) ⇒ String?
Returns the system prompt for this session.
-
#viewport_events(token_budget: Anima::Settings.token_budget, include_pending: true) ⇒ Array<Event>
Returns the events currently visible in the LLM context window.
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.
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.
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.”
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_parent ⇒ void
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.
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.
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_workflow ⇒ void
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_summary ⇒ Array<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.
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.
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. + (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).
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 (token_budget: Anima::Settings.token_budget) heal_orphaned_tool_calls! sliding_budget = token_budget = [] = [] = [] 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 = (token_budget: sliding_budget, include_pending: false) unless sub_agent? first_event_id = events.first&.id = (first_event_id, l2_budget: l2_budget, l1_budget: l1_budget) = (first_event_id, budget: pinned_budget) = (budget: recall_budget) end + + + (ensure_atomic_tool_pairs(events)) end |
#next_view_mode ⇒ String
Cycles to the next view mode: basic → verbose → debug → basic.
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.
346 347 348 349 350 351 352 353 |
# File 'app/models/session.rb', line 346 def 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.
131 132 133 134 135 136 137 138 |
# File 'app/models/session.rb', line 131 def new_ids = .map(&:id) old_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..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 .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).
147 148 149 |
# File 'app/models/session.rb', line 147 def (ids) update_column(:viewport_event_ids, ids) end |
#sub_agent? ⇒ Boolean
Returns 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.
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.
113 114 115 116 117 118 119 120 121 122 123 |
# File 'app/models/session.rb', line 113 def (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 |