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 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
-
.system_prompt_payload(prompt, tools: nil) ⇒ Hash
Builds the system prompt payload for debug mode transmission.
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: version preamble, soul, environment context, skills/workflow, then goals.
-
#broadcast_children_update_to_parent ⇒ void
Broadcasts child session list to all clients subscribed to the parent session.
-
#broadcast_debug_context(system:, tools: nil) ⇒ void
Broadcasts the full LLM debug context to debug-mode TUI clients.
-
#broadcast_session_state(state, tool: nil) ⇒ void
Broadcasts the session’s current processing state to all subscribed clients.
-
#create_user_message(content) ⇒ Message
Persists a user message directly, bypassing the pending queue.
-
#deactivate_skill(skill_name) ⇒ void
Deactivates a skill on this session.
-
#deactivate_workflow ⇒ void
Deactivates the current workflow on this session.
-
#enqueue_user_message(content, bounce_back: false) ⇒ void
Delivers a user message respecting the session’s processing state.
-
#goals_summary ⇒ Array<Hash>
Serializes non-evicted goals as a lightweight summary for ActionCable broadcasts and TUI display.
-
#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.
-
#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 messages into the conversation history.
-
#recalculate_viewport! ⇒ Array<Integer>
Recalculates the viewport and returns IDs of messages 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 message 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.
-
#tool_schemas ⇒ Array<Hash>
Returns the deterministic tool schemas for this session’s type and granted_tools configuration.
-
#viewport_messages(token_budget: Anima::Settings.token_budget) ⇒ Array<Message>
Returns the messages currently visible in the LLM context window.
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.
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.
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.
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.”
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_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.
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.
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}
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.
369 370 371 372 373 374 375 376 |
# File 'app/models/session.rb', line 369 def (content) now = now_ns .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.
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_workflow ⇒ void
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.
350 351 352 353 354 355 356 357 358 |
# File 'app/models/session.rb', line 350 def (content, bounce_back: false) if processing? .create!(content: content) else msg = (content) job_args = bounce_back ? {message_id: msg.id} : {} AgentRequestJob.perform_later(id, **job_args) end end |
#goals_summary ⇒ Array<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.
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.
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 = .where(message_type: "tool_response").select(:tool_use_id) unresponded = .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. + (timeout * 1_000_000_000) next if current_ns < deadline_ns .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).
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 (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 window = (token_budget: sliding_budget) unless sub_agent? = window.first&.id = (, l2_budget: l2_budget, l1_budget: l1_budget) = (, budget: pinned_budget) = (budget: recall_budget) end + + + (ensure_atomic_tool_pairs(window)) end |
#next_view_mode ⇒ String
Cycles to the next view mode: basic → verbose → debug → basic.
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.
385 386 387 388 389 390 391 392 393 394 395 |
# File 'app/models/session.rb', line 385 def promoted = 0 .find_each do |pm| transaction do (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.
135 136 137 138 139 140 141 142 |
# File 'app/models/session.rb', line 135 def new_ids = .map(&:id) old_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 = ..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 .nil? first_conversation = .where(message_type: Message::CONVERSATION_TYPES) .order(:id).first first_conversation ||= .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 .include?() 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).
151 152 153 |
# File 'app/models/session.rb', line 151 def (ids) update_column(:viewport_message_ids, ids) end |
#sub_agent? ⇒ Boolean
Returns 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.
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_schemas ⇒ Array<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).
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.
117 118 119 120 121 122 123 124 125 126 127 |
# File 'app/models/session.rb', line 117 def (token_budget: Anima::Settings.token_budget) own = (, budget: token_budget) remaining = token_budget - own.sum { |msg| (msg) } if sub_agent? && remaining > 0 parent = (, budget: remaining) trim_trailing_tool_calls(parent) + own else own end end |