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 ⇒ String
Assembles the system prompt: version preamble, soul, and snapshots.
-
#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, source_type: nil, source_name: nil) ⇒ 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.
-
#effective_token_budget ⇒ Integer
Token budget appropriate for this session type.
-
#enqueue_user_message(content, source_type: "user", source_name: nil, 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: effective_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! ⇒ Hash{Symbol => Array}
Promotes all pending messages into the conversation history.
-
#promote_phantom_pair!(pm) ⇒ void
Promotes a phantom pair pending message into a tool_call/tool_response pair.
-
#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.
-
#skills_in_viewport ⇒ Set<String>
Returns skill names whose recalled content is currently visible in the viewport.
-
#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 ⇒ 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: effective_token_budget) ⇒ Array<Message>
Returns the messages currently visible in the LLM context window.
-
#workflow_in_viewport ⇒ String?
Returns the workflow name currently visible in the viewport, if any.
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.
561 562 563 564 565 566 567 568 569 570 571 572 573 574 |
# File 'app/models/session.rb', line 561 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, updates active_skills, and enqueues the skill content as a PendingMessage so it enters the conversation as a phantom tool_use/tool_result pair through the normal promotion flow.
195 196 197 198 199 200 201 202 203 204 205 |
# File 'app/models/session.rb', line 195 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! ("skill", skill_name, definition.content) 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 enqueues the workflow content as a PendingMessage. Only one workflow can be active at a time —activating a new one replaces the previous.
228 229 230 231 232 233 234 235 236 237 238 |
# File 'app/models/session.rb', line 228 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! ("workflow", workflow_name, definition.content) definition end |
#assemble_system_prompt ⇒ String
Assembles the system prompt: version preamble, soul, and snapshots. Skills, workflows, goals, and environment awareness flow through the message stream and tool responses, keeping the system prompt stable for prompt caching.
257 258 259 260 |
# File 'app/models/session.rb', line 257 def assemble_system_prompt [assemble_version_preamble, assemble_soul_section, assemble_snapshots_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.
471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 |
# File 'app/models/session.rb', line 471 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.
521 522 523 524 525 |
# File 'app/models/session.rb', line 521 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}
501 502 503 504 505 506 507 508 509 510 511 512 |
# File 'app/models/session.rb', line 501 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, source_type: nil, source_name: nil) ⇒ 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.
417 418 419 420 421 422 423 424 425 426 427 |
# File 'app/models/session.rb', line 417 def (content, source_type: nil, source_name: nil) now = now_ns payload = {type: "user_message", content: content, session_id: id, timestamp: now} payload["source_type"] = source_type if source_type payload["source_name"] = source_name if source_name .create!( message_type: "user_message", payload: payload, 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. The skill’s recalled message stays in the conversation and evicts naturally.
212 213 214 215 216 217 |
# File 'app/models/session.rb', line 212 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. The workflow’s recalled message stays in the conversation and evicts naturally.
244 245 246 247 248 249 |
# File 'app/models/session.rb', line 244 def deactivate_workflow return unless active_workflow.present? self.active_workflow = nil save! end |
#effective_token_budget ⇒ Integer
Token budget appropriate for this session type. Sub-agents use a smaller budget to stay out of the “dumb zone”.
105 106 107 |
# File 'app/models/session.rb', line 105 def effective_token_budget sub_agent? ? Anima::Settings.subagent_token_budget : Anima::Settings.token_budget end |
#enqueue_user_message(content, source_type: "user", source_name: nil, 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.
359 360 361 362 363 364 365 366 367 368 369 370 371 372 |
# File 'app/models/session.rb', line 359 def (content, source_type: "user", source_name: nil, bounce_back: false) if processing? .create!(content: content, source_type: source_type, source_name: source_name) else display = if source_type == "subagent" format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content) else content end msg = (display) 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.
268 269 270 |
# File 'app/models/session.rb', line 268 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.
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 |
# File 'app/models/session.rb', line 316 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: effective_token_budget) ⇒ Array<Hash>
Builds the message array expected by the Anthropic Messages API. Viewport layout (top to bottom):
[context prefix: goals + pinned messages] [sliding window messages]
Snapshots live in the system prompt (stable between Mneme runs). Goal events and recalled memories flow through the message stream as phantom tool pairs — they ride the conveyor belt as regular messages. After eviction, a goal snapshot + pinned messages block is rebuilt from DB state and prepended as a phantom pair.
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.
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'app/models/session.rb', line 288 def (token_budget: effective_token_budget) heal_orphaned_tool_calls! sliding_budget = token_budget pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i sliding_budget -= pinned_budget window = (token_budget: sliding_budget) = window.first&.id prefix = (, budget: pinned_budget) prefix + (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! ⇒ Hash{Symbol => Array}
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 a hash with two keys:
-
:texts— plain content strings for user messages (injected as text blocks within the current tool_results turn) -
:pairs— synthetic tool_use/tool_result message hashes for phantom pair types (appended as new conversation turns)
442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 |
# File 'app/models/session.rb', line 442 def texts = [] pairs = [] .find_each do |pm| transaction do if pm.phantom_pair? promote_phantom_pair!(pm) else (pm.display_content, source_type: pm.source_type, source_name: pm.source_name) end pm.destroy! end if pm.phantom_pair? pairs.concat(pm.) else texts << pm.content end end {texts: texts, pairs: pairs} end |
#promote_phantom_pair!(pm) ⇒ void
This method returns an undefined value.
Promotes a phantom pair pending message into a tool_call/tool_response pair. These persist as real Message records and ride the conveyor belt.
379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 |
# File 'app/models/session.rb', line 379 def promote_phantom_pair!(pm) tool_name = pm.phantom_tool_name tool_input = pm.phantom_tool_input uid = "#{tool_name}_#{pm.id}" now = now_ns .create!( message_type: "tool_call", tool_use_id: uid, payload: {"tool_name" => tool_name, "tool_use_id" => uid, "tool_input" => tool_input.stringify_keys, "content" => pm.display_content.lines.first.chomp}, timestamp: now, token_count: Mneme::PassiveRecall::TOOL_PAIR_OVERHEAD_TOKENS ) .create!( message_type: "tool_response", tool_use_id: uid, payload: {"tool_name" => tool_name, "tool_use_id" => uid, "content" => pm.content, "success" => true}, timestamp: now, token_count: Message.estimate_token_count(pm.content.bytesize) ) 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.
129 130 131 132 133 134 135 136 |
# File 'app/models/session.rb', line 129 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 |
#skills_in_viewport ⇒ Set<String>
Returns skill names whose recalled content is currently visible in the viewport. Used by the analytical brain for deduplication — skills already in the viewport are excluded from the activation catalog.
154 155 156 |
# File 'app/models/session.rb', line 154 def ("skill") 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).
145 146 147 |
# File 'app/models/session.rb', line 145 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 ⇒ 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 and snapshots. Skills, workflows, and goals are injected as phantom tool_use/tool_result pairs in the message stream (not here) to keep the system prompt stable for prompt caching. Environment awareness flows through Bash tool responses.
Sub-agent sessions still include expertise inline — they’re short-lived and don’t benefit from prompt caching.
178 179 180 181 182 183 184 |
# File 'app/models/session.rb', line 178 def system_prompt if sub_agent? [prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n") else assemble_system_prompt 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).
534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 |
# File 'app/models/session.rb', line 534 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: effective_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.
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.
119 120 121 |
# File 'app/models/session.rb', line 119 def (token_budget: effective_token_budget) (, budget: token_budget) end |
#workflow_in_viewport ⇒ String?
Returns the workflow name currently visible in the viewport, if any. Only one workflow can be active at a time, so we return the first match.
162 163 164 |
# File 'app/models/session.rb', line 162 def ("workflow").first end |