anima-core 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dca02bfff536637c003d5f3bbce8dbe20992b7eeb25e6c51bdb4991a2803b538
4
- data.tar.gz: ead68cc1bd03306a9eef644db2f15a81b7dbfd74bbe1f059e3f57bcfc0aaf77a
3
+ metadata.gz: 161b7dfde73fa61f7656427c0af3c5919e1a1cc50d6b1a2d201f5a45ce79c561
4
+ data.tar.gz: 880538d54fbc682b61bbeb6d63b38aa066e86327c778fefce8e60091c6ad816d
5
5
  SHA512:
6
- metadata.gz: 45f7f927d4f931b624db684e5f500c436cac44cf9dd9a004400b7219f1167e932b181dddac3793d4cd8f1885cf6401855ba6ca681c99d7cd902af8784e49cee2
7
- data.tar.gz: 78e532c99e39f09732c9abe9588e467a96fb38cddaf7d1b8ba526971de1c890e73efd157876cb570eabad0cb0eb7c1f93faeef68c7128f7806f972ff98e2351c
6
+ metadata.gz: f0ee765c1a125e26b9f10b5cc97f41e857ec17562ef4ba116dd53bf97b9b26fb3781d9f292c6296c1c408089d155d9d8a24e4165d58f57ead64e72595f833ae8
7
+ data.tar.gz: 86c95c9f103cb0c6c4b37ef3452662274205da97ef04d3f846f7f2d041ff0c94f375208b60d245f454aa73abb2c144113f9d6691cc99a0124f43971fcfb62ae1
data/.reek.yml CHANGED
@@ -39,6 +39,8 @@ detectors:
39
39
  - "Tools::SubagentPrompts#assign_nickname_via_brain"
40
40
  # Validation methods naturally reference the validated value more than self.
41
41
  - "AnalyticalBrain::Tools::AssignNickname#validate"
42
+ # Delivery method orchestrates session, event, and agent_loop — inherent.
43
+ - "AgentRequestJob#deliver_persisted_event"
42
44
  # Private helpers don't need instance state to be valid.
43
45
  # ActiveJob#perform is always a utility function by design.
44
46
  # No-op tools (Think, EverythingIsReady) don't need instance state — by design.
@@ -90,6 +92,8 @@ detectors:
90
92
  - "Tools::Remember"
91
93
  # Nickname validation checks parent_session for existence then queries — two calls, one guard.
92
94
  - "AnalyticalBrain::Tools::AssignNickname#sibling_nickname_taken?"
95
+ # Delivery method references session.id for lookup, BounceBack, and auth broadcast.
96
+ - "AgentRequestJob#deliver_persisted_event"
93
97
  # Method length is enforced by code review, not arbitrary line counts
94
98
  # build_sections passes context through to sub-methods — inherent to assembly.
95
99
  LongParameterList:
data/README.md CHANGED
@@ -132,8 +132,7 @@ State directory (`~/.anima/`):
132
132
  ├── config.toml # Main settings (hot-reloadable)
133
133
  ├── mcp.toml # MCP server configuration
134
134
  ├── config/
135
- ├── credentials/ # Rails encrypted credentials per environment
136
- │ └── anima.yml # Placeholder config
135
+ └── credentials/ # Rails encrypted credentials per environment
137
136
  ├── agents/ # User-defined specialist agents (override built-ins)
138
137
  ├── skills/ # User-defined skills (override built-ins)
139
138
  ├── workflows/ # User-defined workflows (override built-ins)
@@ -142,7 +141,7 @@ State directory (`~/.anima/`):
142
141
  └── tmp/
143
142
  ```
144
143
 
145
- Updates: `anima update` — upgrades the gem and merges new config settings into your existing `config.toml` without overwriting customized values. Use `anima update --migrate-only` to skip the gem upgrade and only add missing config keys.
144
+ Updates: `anima update` — upgrades the gem, merges new config settings into your existing `config.toml` without overwriting customized values, and restarts the systemd service if it's running. Use `anima update --migrate-only` to skip the gem upgrade and only add missing config keys.
146
145
 
147
146
  ### Authentication Setup
148
147
 
@@ -300,6 +299,7 @@ blocking_on_user_message = true
300
299
  event_window = 20
301
300
 
302
301
  [session]
302
+ default_view_mode = "basic"
303
303
  name_generation_interval = 30
304
304
  ```
305
305
 
@@ -42,14 +42,13 @@ class SessionChannel < ApplicationCable::Channel
42
42
  ActionCable.server.broadcast(stream_name, data)
43
43
  end
44
44
 
45
- # Processes user input by emitting a {Events::UserMessage} on the event bus.
45
+ # Processes user input. For idle sessions, persists the event immediately
46
+ # so the message appears in the TUI without waiting for the background
47
+ # job, then schedules {AgentRequestJob} for LLM delivery. If delivery
48
+ # fails, the job deletes the event and emits a {Events::BounceBack}.
46
49
  #
47
- # When the session is idle, the emission triggers {Events::Subscribers::AgentDispatcher}
48
- # which schedules {AgentRequestJob} to persist the event and deliver it to the LLM
49
- # inside a transaction (Bounce Back, #236).
50
- #
51
- # When the session is already processing, the message is queued as "pending"
52
- # and picked up after the current agent loop completes.
50
+ # For busy sessions, emits a pending {Events::UserMessage} that queues
51
+ # until the current agent loop completes.
53
52
  #
54
53
  # @param data [Hash] must include "content" with the user's message text
55
54
  def speak(data)
@@ -62,7 +61,8 @@ class SessionChannel < ApplicationCable::Channel
62
61
  if session.processing?
63
62
  Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
64
63
  else
65
- Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
64
+ event = session.create_user_event(content)
65
+ AgentRequestJob.perform_later(session.id, event_id: event.id)
66
66
  end
67
67
  end
68
68
 
@@ -6,7 +6,8 @@ require "toon"
6
6
  # Hidden in basic mode — tool activity is represented by the
7
7
  # aggregated tool counter instead. Verbose mode returns tool name
8
8
  # and a formatted preview of the input arguments. Debug mode shows
9
- # full untruncated input in TOON format with tool_use_id.
9
+ # full untruncated input with tool_use_id — TOON format for most
10
+ # tools, but write tool content preserves actual newlines.
10
11
  #
11
12
  # Think tool calls are special: "aloud" thoughts are shown in all
12
13
  # view modes (with a thought bubble), while "inner" thoughts are
@@ -41,7 +42,7 @@ class ToolCallDecorator < EventDecorator
41
42
  {
42
43
  role: :tool_call,
43
44
  tool: payload["tool_name"],
44
- input: Toon.encode(payload["tool_input"] || {}),
45
+ input: format_debug_input,
45
46
  tool_use_id: payload["tool_use_id"],
46
47
  timestamp: timestamp
47
48
  }
@@ -90,6 +91,30 @@ class ToolCallDecorator < EventDecorator
90
91
  {role: :think, content: thoughts, visibility: visibility, tool_use_id: payload["tool_use_id"], timestamp: timestamp}
91
92
  end
92
93
 
94
+ # Full tool input for debug mode. Write tool content is shown with
95
+ # preserved newlines instead of TOON-escaping them into literal \n.
96
+ # @return [String] formatted debug input
97
+ def format_debug_input
98
+ input = tool_input
99
+ case payload["tool_name"]
100
+ when "write" then format_write_content(input)
101
+ else Toon.encode(input)
102
+ end
103
+ end
104
+
105
+ # Formats write tool input with file path header and content body.
106
+ # Content newlines are preserved so the TUI can render them as
107
+ # separate lines, matching how read tool responses display file content.
108
+ # @param input [Hash] tool input hash with "file_path" and "content" keys
109
+ # @return [String] path + content with real newlines, or TOON-encoded hash when content is empty
110
+ def format_write_content(input)
111
+ path = input.dig("file_path").to_s
112
+ content = input.dig("content").to_s
113
+ return Toon.encode(input) if content.empty?
114
+
115
+ "#{path}\n#{content}"
116
+ end
117
+
93
118
  # Formats tool input for display, with tool-specific formatting for
94
119
  # known tools and generic JSON fallback for others.
95
120
  # @return [String] formatted input preview
@@ -5,16 +5,18 @@
5
5
  #
6
6
  # Supports two modes:
7
7
  #
8
- # **Bounce Back (content provided):** Persists the user event and verifies
9
- # LLM delivery inside a single transaction. If the first API call fails,
10
- # the transaction rolls back (event never existed) and a {Events::BounceBack}
11
- # is emitted so clients can restore the text to the input field.
8
+ # **Immediate Persist (event_id provided):** The user event was already
9
+ # persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
10
+ # The job verifies LLM delivery if the first API call fails, the
11
+ # event is deleted and a {Events::BounceBack} is emitted so clients
12
+ # can restore the text to the input field.
12
13
  #
13
- # **Standard (no content):** Processes already-persisted events (e.g. after
14
- # pending message promotion). Uses ActiveJob retry/discard for error handling.
14
+ # **Standard (no event_id):** Processes already-persisted events (e.g.
15
+ # after pending message promotion). Uses ActiveJob retry/discard for
16
+ # error handling.
15
17
  #
16
- # @example Bounce Back — event-driven via AgentDispatcher
17
- # AgentRequestJob.perform_later(session.id, content: "hello")
18
+ # @example Immediate Persist — event already saved by SessionChannel
19
+ # AgentRequestJob.perform_later(session.id, event_id: 42)
18
20
  #
19
21
  # @example Standard — pending message processing
20
22
  # AgentRequestJob.perform_later(session.id)
@@ -24,7 +26,7 @@ class AgentRequestJob < ApplicationJob
24
26
  # ActionCable action signaling clients to prompt for an API token.
25
27
  AUTH_REQUIRED_ACTION = "authentication_required"
26
28
 
27
- # Standard path only — bounce back handles its own errors.
29
+ # Standard path only — immediate persist handles its own errors.
28
30
  retry_on Providers::Anthropic::TransientError,
29
31
  wait: :polynomially_longer, attempts: 5 do |job, error|
30
32
  Events::Bus.emit(Events::SystemMessage.new(
@@ -47,8 +49,8 @@ class AgentRequestJob < ApplicationJob
47
49
  end
48
50
 
49
51
  # @param session_id [Integer] ID of the session to process
50
- # @param content [String, nil] user message text (triggers Bounce Back when present)
51
- def perform(session_id, content: nil)
52
+ # @param event_id [Integer, nil] ID of a pre-persisted user event (triggers delivery verification)
53
+ def perform(session_id, event_id: nil)
52
54
  session = Session.find(session_id)
53
55
 
54
56
  # Atomic: only one job processes a session at a time.
@@ -58,8 +60,8 @@ class AgentRequestJob < ApplicationJob
58
60
 
59
61
  agent_loop = AgentLoop.new(session: session)
60
62
 
61
- if content
62
- deliver_with_bounce_back(session, content, agent_loop)
63
+ if event_id
64
+ deliver_persisted_event(session, event_id, agent_loop)
63
65
  else
64
66
  agent_loop.run
65
67
  end
@@ -80,51 +82,52 @@ class AgentRequestJob < ApplicationJob
80
82
 
81
83
  private
82
84
 
83
- # Persists the user event and verifies LLM delivery atomically.
85
+ # Verifies LLM delivery for a pre-persisted user event.
84
86
  #
85
- # Inside a transaction: creates the event record, broadcasts it for
86
- # optimistic UI, and makes the first LLM API call. If the call fails,
87
- # a {Events::BounceBack} is emitted and the exception re-raised to
88
- # trigger rollback the event never existed in the database.
87
+ # The event was already created and broadcast by the caller, so
88
+ # the user sees their message immediately. This method makes the
89
+ # first LLM API call if it fails, the event is deleted and a
90
+ # {Events::BounceBack} notifies clients to remove the phantom
91
+ # message and restore the text to the input field. For
92
+ # {Providers::Anthropic::AuthenticationError}, an additional
93
+ # +authentication_required+ broadcast prompts the client to show
94
+ # the token entry dialog.
89
95
  #
90
- # After commit: continues the agent loop (tool execution, subsequent
91
- # API calls) outside the transaction so tool events broadcast in
92
- # real time.
96
+ # Unlike the standard path (which uses +retry_on+ / +discard_on+),
97
+ # all errors here are caught and swallowed after emitting a
98
+ # BounceBack — the job completes normally so ActiveJob does not
99
+ # retry a message the user will re-send manually.
100
+ #
101
+ # After successful delivery, continues the agent loop (tool
102
+ # execution, subsequent API calls).
93
103
  #
94
104
  # @param session [Session] the conversation session
95
- # @param content [String] user message text
96
- # @param agent_loop [AgentLoop] agent loop instance (reused after commit)
97
- def deliver_with_bounce_back(session, content, agent_loop)
98
- event_id = nil
99
-
100
- ActiveRecord::Base.transaction do
101
- event = persist_user_event(session, content)
102
- event_id = event.id
103
- event.broadcast_now!
104
-
105
+ # @param event_id [Integer] database ID of the pre-persisted user event
106
+ # @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
107
+ def deliver_persisted_event(session, event_id, agent_loop)
108
+ event = Event.find_by(id: event_id, session_id: session.id)
109
+ # Event may have been deleted between SessionChannel#speak and job
110
+ # execution (e.g. user recalled the message). Exit silently — there
111
+ # is nothing to deliver or bounce back.
112
+ return unless event
113
+
114
+ content = event.payload["content"]
115
+
116
+ begin
105
117
  agent_loop.deliver!
106
118
  rescue => error
119
+ event.destroy!
107
120
  Events::Bus.emit(Events::BounceBack.new(
108
121
  content: content,
109
122
  error: error.message,
110
123
  session_id: session.id,
111
124
  event_id: event_id
112
125
  ))
113
- raise
126
+ broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
127
+ return
114
128
  end
115
129
 
116
- # Transaction committed — first call succeeded.
117
- # Continue processing (tool execution, etc.) outside the transaction.
118
130
  agent_loop.run
119
- rescue => error
120
- # Bounce already emitted inside the transaction rescue.
121
- # Also trigger auth popup for authentication errors.
122
- broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
123
- end
124
-
125
- # @see Session#create_user_event
126
- def persist_user_event(session, content)
127
- session.create_user_event(content)
128
131
  end
129
132
 
130
133
  def broadcast_auth_required(session_id, error)
@@ -44,23 +44,9 @@ module Event::Broadcasting
44
44
  after_update_commit :broadcast_update
45
45
  end
46
46
 
47
- # Broadcasts this event immediately, bypassing +after_create_commit+.
48
- # Used inside a wrapping transaction where +after_create_commit+ is
49
- # deferred until the outer transaction commits. Gives clients
50
- # optimistic UI — the event appears right away and is removed via
51
- # bounce if the transaction rolls back.
52
- #
53
- # Sets a flag so the deferred +after_create_commit+ callback skips
54
- # the duplicate broadcast after the transaction commits.
55
- def broadcast_now!
56
- @already_broadcast = true
57
- broadcast_event(action: ACTION_CREATE)
58
- end
59
-
60
47
  private
61
48
 
62
49
  def broadcast_create
63
- return if @already_broadcast
64
50
  broadcast_event(action: ACTION_CREATE)
65
51
  end
66
52
 
@@ -11,6 +11,8 @@ class Session < ApplicationRecord
11
11
 
12
12
  VIEW_MODES = %w[basic verbose debug].freeze
13
13
 
14
+ attribute :view_mode, :string, default: -> { Anima::Settings.default_view_mode }
15
+
14
16
  serialize :granted_tools, coder: JSON
15
17
 
16
18
  has_many :events, -> { order(:id) }, dependent: :destroy
@@ -251,6 +253,8 @@ class Session < ApplicationRecord
251
253
  # @param token_budget [Integer] maximum tokens to include (positive)
252
254
  # @return [Array<Hash>] Anthropic Messages API format
253
255
  def messages_for_llm(token_budget: Anima::Settings.token_budget)
256
+ heal_orphaned_tool_calls!
257
+
254
258
  sliding_budget = token_budget
255
259
  snapshot_messages = []
256
260
  pinned_messages = []
@@ -273,11 +277,52 @@ class Session < ApplicationRecord
273
277
  recall_messages = assemble_recall_messages(budget: recall_budget)
274
278
  end
275
279
 
276
- snapshot_messages + pinned_messages + recall_messages + assemble_messages(events)
280
+ snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(events))
281
+ end
282
+
283
+ # Detects orphaned tool_call events (those without a matching tool_response
284
+ # and whose timeout has expired) and creates synthetic error responses.
285
+ # An orphaned tool_call permanently breaks the session because the
286
+ # Anthropic API rejects conversations where a tool_use block has no
287
+ # matching tool_result.
288
+ #
289
+ # Respects the per-call timeout stored in the tool_call event payload —
290
+ # a tool_call is only healed after its deadline has passed. This avoids
291
+ # prematurely healing long-running tools that the agent intentionally
292
+ # gave an extended timeout.
293
+ #
294
+ # @return [Integer] number of synthetic responses created
295
+ def heal_orphaned_tool_calls!
296
+ now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
297
+ responded_ids = events.where(event_type: "tool_response").where.not(tool_use_id: nil).select(:tool_use_id)
298
+ unresponded = events.where(event_type: "tool_call").where.not(tool_use_id: nil)
299
+ .where.not(tool_use_id: responded_ids)
300
+
301
+ healed = 0
302
+ unresponded.find_each do |orphan|
303
+ timeout = orphan.payload["timeout"] || Anima::Settings.tool_timeout
304
+ deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
305
+ next if now_ns < deadline_ns
306
+
307
+ events.create!(
308
+ event_type: "tool_response",
309
+ payload: {
310
+ "type" => "tool_response",
311
+ "content" => "Tool execution timed out after #{timeout} seconds — no result was returned.",
312
+ "tool_name" => orphan.payload["tool_name"],
313
+ "tool_use_id" => orphan.tool_use_id,
314
+ "success" => false
315
+ },
316
+ tool_use_id: orphan.tool_use_id,
317
+ timestamp: now_ns
318
+ )
319
+ healed += 1
320
+ end
321
+ healed
277
322
  end
278
323
 
279
324
  # Creates a user message event record directly (bypasses EventBus+Persister).
280
- # Used by {AgentRequestJob} (Bounce Back transaction), {AgentLoop#process},
325
+ # Used by {SessionChannel#speak} (immediate display), {AgentLoop#process},
281
326
  # and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
282
327
  # because the global {Events::Subscribers::Persister} skips non-pending user
283
328
  # messages — these callers own the persistence lifecycle.
@@ -497,6 +542,28 @@ class Session < ApplicationRecord
497
542
  event_list
498
543
  end
499
544
 
545
+ # Ensures every tool_call in the event list has a matching tool_response
546
+ # (and vice versa) by removing unpaired events. The Anthropic API requires
547
+ # every tool_use block to have a tool_result — a missing partner causes
548
+ # a permanent API error. Token budget cutoffs can split pairs when the
549
+ # boundary falls between a tool_call and its tool_response.
550
+ #
551
+ # @param event_list [Array<Event>] chronologically ordered events
552
+ # @return [Array<Event>] events with unpaired tool events removed
553
+ def ensure_atomic_tool_pairs(event_list)
554
+ tool_events = event_list.select { |e| e.tool_use_id.present? }
555
+ return event_list if tool_events.empty?
556
+
557
+ paired = tool_events.group_by(&:tool_use_id)
558
+ complete_ids = paired.each_with_object(Set.new) do |(id, evts), set|
559
+ has_call = evts.any? { |e| e.event_type == "tool_call" }
560
+ has_response = evts.any? { |e| e.event_type == "tool_response" }
561
+ set << id if has_call && has_response
562
+ end
563
+
564
+ event_list.reject { |e| e.tool_use_id.present? && !complete_ids.include?(e.tool_use_id) }
565
+ end
566
+
500
567
  # Selects visible snapshots and formats them as Anthropic messages.
501
568
  # Snapshots are visible when their source events have fully evicted.
502
569
  # L1 snapshots are excluded when covered by an L2 snapshot.
data/bin/jobs CHANGED
@@ -3,4 +3,9 @@
3
3
  require_relative "../config/environment"
4
4
  require "solid_queue/cli"
5
5
 
6
+ # Run all Solid Queue components (worker, dispatcher, scheduler) as threads
7
+ # in a single process. Fork mode spawns child processes that can outlive
8
+ # the supervisor and run stale code after gem updates (#275).
9
+ ENV["SOLID_QUEUE_SUPERVISOR_MODE"] = "async"
10
+
6
11
  SolidQueue::Cli.start(ARGV)
@@ -6,12 +6,10 @@
6
6
  Rails.application.config.after_initialize do
7
7
  unless Rails.env.test?
8
8
  # Global persister handles events from all sessions (brain server, background jobs).
9
- # Skips non-pending user messages — those are persisted by AgentRequestJob.
9
+ # Skips non-pending user messages — those are persisted by their callers
10
+ # (SessionChannel#speak for idle sessions, AgentLoop#process for direct usage).
10
11
  Events::Bus.subscribe(Events::Subscribers::Persister.new)
11
12
 
12
- # Schedules AgentRequestJob when a non-pending user message is emitted.
13
- Events::Bus.subscribe(Events::Subscribers::AgentDispatcher.new)
14
-
15
13
  # Bridges transient events (e.g. BounceBack) to ActionCable for client delivery.
16
14
  Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
17
15
 
data/config/queue.yml CHANGED
@@ -5,7 +5,6 @@ default: &default
5
5
  workers:
6
6
  - queues: "*"
7
7
  threads: 3
8
- processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
9
8
  polling_interval: 0.1
10
9
 
11
10
  development:
data/lib/anima/cli.rb CHANGED
@@ -20,13 +20,17 @@ module Anima
20
20
  Installer.new.run
21
21
  end
22
22
 
23
- desc "update", "Upgrade gem and migrate config"
23
+ desc "update", "Upgrade gem, migrate config, and restart service"
24
24
  option :migrate_only, type: :boolean, default: false, desc: "Skip gem upgrade, only migrate config"
25
25
  def update
26
+ require_relative "spinner"
27
+
26
28
  unless options[:migrate_only]
27
- say "Upgrading anima-core gem..."
28
- unless system("gem", "update", "anima-core")
29
- say "Gem update failed.", :red
29
+ success = Spinner.run("Upgrading anima-core gem...") do
30
+ system("gem", "update", "anima-core", out: File::NULL, err: File::NULL)
31
+ end
32
+ unless success
33
+ say "Run manually for details: gem update anima-core", :red
30
34
  exit 1
31
35
  end
32
36
 
@@ -34,22 +38,24 @@ module Anima
34
38
  exec(File.join(Gem.bindir, "anima"), "update", "--migrate-only")
35
39
  end
36
40
 
37
- say "Migrating configuration..."
38
41
  require_relative "config_migrator"
39
- result = Anima::ConfigMigrator.new.run
42
+ result = Spinner.run("Migrating configuration...") do
43
+ Anima::ConfigMigrator.new.run
44
+ end
40
45
 
41
46
  case result.status
42
47
  when :not_found
43
48
  say "Config file not found. Run 'anima install' first.", :red
44
49
  exit 1
45
50
  when :up_to_date
46
- say "Config is already up to date."
51
+ say " Config is already up to date."
47
52
  when :updated
48
53
  result.additions.each do |addition|
49
54
  say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
50
55
  end
51
- say "Config updated. Changes take effect immediately — no restart needed."
52
56
  end
57
+
58
+ restart_service_if_active
53
59
  end
54
60
 
55
61
  # Start the Anima brain server (Puma + Solid Queue) via Foreman.
@@ -104,5 +110,21 @@ module Anima
104
110
  subcommand "mcp", Mcp
105
111
 
106
112
  private
113
+
114
+ # Restarts the systemd user service so updated code takes effect.
115
+ # Without this, the service continues running the old gem version
116
+ # until manually restarted (see #269).
117
+ #
118
+ # @return [void]
119
+ def restart_service_if_active
120
+ return unless system("systemctl", "--user", "is-active", "--quiet", "anima.service")
121
+
122
+ success = Spinner.run("Restarting anima service...") do
123
+ system("systemctl", "--user", "restart", "anima.service")
124
+ end
125
+ unless success
126
+ say " Run manually: systemctl --user restart anima.service", :red
127
+ end
128
+ end
107
129
  end
108
130
  end
@@ -30,7 +30,6 @@ module Anima
30
30
  say "Installing Anima to #{anima_home}..."
31
31
  create_directories
32
32
  create_soul_file
33
- create_config_file
34
33
  create_settings_config
35
34
  create_mcp_config
36
35
  generate_credentials
@@ -60,17 +59,6 @@ module Anima
60
59
  say " created #{soul_path}"
61
60
  end
62
61
 
63
- def create_config_file
64
- config_path = anima_home.join("config", "anima.yml")
65
- return if config_path.exist?
66
-
67
- config_path.write(<<~YAML)
68
- # Anima configuration
69
- # See https://github.com/hoblin/anima for documentation
70
- YAML
71
- say " created #{config_path}"
72
- end
73
-
74
62
  def create_settings_config
75
63
  config_path = anima_home.join("config.toml")
76
64
  return if config_path.exist?
@@ -102,6 +102,11 @@ module Anima
102
102
  # @return [Integer] seconds
103
103
  def web_request_timeout = get("timeouts", "web_request")
104
104
 
105
+ # Per-tool-call timeout. Used as the default deadline for orphan detection
106
+ # and as the default value for the tool's `timeout` input parameter.
107
+ # @return [Integer] seconds
108
+ def tool_timeout = get("timeouts", "tool")
109
+
105
110
  # ─── Shell ──────────────────────────────────────────────────────
106
111
 
107
112
  # Maximum bytes of command output before truncation.
@@ -128,6 +133,19 @@ module Anima
128
133
 
129
134
  # ─── Session ────────────────────────────────────────────────────
130
135
 
136
+ # View mode applied to new sessions: "basic", "verbose", or "debug".
137
+ # Changing this setting only affects sessions created afterwards.
138
+ # @return [String]
139
+ # @raise [MissingSettingError] if the value is not a valid view mode
140
+ def default_view_mode
141
+ value = get("session", "default_view_mode")
142
+ unless Session::VIEW_MODES.include?(value)
143
+ raise MissingSettingError,
144
+ "[session] default_view_mode must be one of: #{Session::VIEW_MODES.join(", ")} (got #{value.inspect})"
145
+ end
146
+ value
147
+ end
148
+
131
149
  # Regenerate session name every N messages.
132
150
  # @return [Integer]
133
151
  def name_generation_interval = get("session", "name_generation_interval")
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anima
4
+ # Braille spinner for long-running CLI operations.
5
+ # Animates in a background thread while a block executes in the
6
+ # calling thread, then shows a success/failure indicator.
7
+ #
8
+ # @example
9
+ # result = Spinner.run("Installing...") { system("make install") }
10
+ class Spinner
11
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
12
+ FRAME_DELAY = 0.08
13
+ SUCCESS_ICON = "\u2713"
14
+ FAILURE_ICON = "\u2717"
15
+ JOIN_TIMEOUT = 2
16
+
17
+ # Run a block with an animated spinner beside a status message.
18
+ #
19
+ # @param message [String] status text shown beside the spinner
20
+ # @param output [IO] output stream (defaults to $stdout)
21
+ # @yield the operation to run
22
+ # @return [Object] the block's return value
23
+ def self.run(message, output: $stdout, &block)
24
+ new(message, output: output).run(&block)
25
+ end
26
+
27
+ # @param message [String] status text shown beside the spinner
28
+ # @param output [IO] output stream
29
+ def initialize(message, output: $stdout)
30
+ @message = message
31
+ @output = output
32
+ @mutex = Mutex.new
33
+ @running = false
34
+ end
35
+
36
+ # @yield operation to run while the spinner animates
37
+ # @return [Object] the block's return value
38
+ def run
39
+ thread = start_animation
40
+ result = yield
41
+ stop_animation(thread, success: !!result)
42
+ result
43
+ rescue
44
+ stop_animation(thread, success: false)
45
+ raise
46
+ end
47
+
48
+ private
49
+
50
+ def running?
51
+ @mutex.synchronize { @running }
52
+ end
53
+
54
+ def start_animation
55
+ @mutex.synchronize { @running = true }
56
+ Thread.new do
57
+ idx = 0
58
+ while running?
59
+ @output.print "\r#{FRAMES[idx % FRAMES.size]} #{@message}"
60
+ @output.flush
61
+ idx += 1
62
+ sleep FRAME_DELAY
63
+ end
64
+ end
65
+ end
66
+
67
+ def stop_animation(thread, success:)
68
+ @mutex.synchronize { @running = false }
69
+ thread.join(JOIN_TIMEOUT)
70
+ icon = success ? SUCCESS_ICON : FAILURE_ICON
71
+ @output.print "\r#{icon} #{@message}\n"
72
+ @output.flush
73
+ end
74
+ end
75
+ end
data/lib/anima/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anima
4
- VERSION = "1.1.0"
4
+ VERSION = "1.1.1"
5
5
  end
@@ -123,7 +123,7 @@ class EnvironmentProbe
123
123
  # @return [Hash{Symbol => String}, nil] keys: :remote, :repo_name, :branch,
124
124
  # and optionally :pr_number (Integer) and :pr_state (String); nil when not in a repo
125
125
  def detect_git
126
- _, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree")
126
+ _, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree", err: File::NULL)
127
127
  return unless status.success?
128
128
 
129
129
  info = {}
@@ -136,7 +136,7 @@ class EnvironmentProbe
136
136
 
137
137
  # Populates :remote and :repo_name on the info hash.
138
138
  def detect_git_remote(info)
139
- remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin")
139
+ remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin", err: File::NULL)
140
140
  remote = remote.strip
141
141
  return unless remote.present?
142
142
 
@@ -146,7 +146,7 @@ class EnvironmentProbe
146
146
 
147
147
  # Populates :branch, :pr_number, and :pr_state on the info hash.
148
148
  def detect_git_branch(info)
149
- branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD")
149
+ branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD", err: File::NULL)
150
150
  branch = branch.strip
151
151
  return unless branch.present?
152
152
 
@@ -181,7 +181,7 @@ class EnvironmentProbe
181
181
  output, status = Open3.capture2(
182
182
  "gh", "pr", "list", "--head", branch,
183
183
  "--json", "number,state", "--limit", "1",
184
- chdir: @pwd
184
+ chdir: @pwd, err: File::NULL
185
185
  )
186
186
  return unless status.success?
187
187
 
@@ -28,10 +28,10 @@ module Events
28
28
 
29
29
  # Receives a Rails.event notification hash and persists it.
30
30
  #
31
- # Skips non-pending user messages — those are persisted by
32
- # {AgentRequestJob} inside a transaction with LLM delivery
33
- # (Bounce Back, #236). Also skips event types not in {Event::TYPES}
34
- # (transient events like {Events::BounceBack}).
31
+ # Skips non-pending user messages — those are persisted by their
32
+ # callers ({SessionChannel#speak} for idle sessions,
33
+ # {AgentLoop#process} for direct usage). Also skips event types
34
+ # not in {Event::TYPES} (transient events like {Events::BounceBack}).
35
35
  #
36
36
  # @param event [Hash] with :payload containing event data
37
37
  def emit(event)
@@ -63,9 +63,11 @@ module Events
63
63
 
64
64
  private
65
65
 
66
- # Non-pending user messages are persisted by {AgentRequestJob} inside
67
- # a transaction with LLM delivery. Pending messages are still
68
- # auto-persisted here because they queue while the session is busy.
66
+ # Non-pending user messages are persisted by their callers
67
+ # ({SessionChannel#speak}, {AgentLoop#process}) so the event ID
68
+ # is available for bounce-back cleanup if LLM delivery fails.
69
+ # Pending messages are still auto-persisted here because they
70
+ # queue while the session is busy.
69
71
  def persisted_by_job?(event_type, payload)
70
72
  event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
71
73
  end
@@ -4,18 +4,20 @@ module Events
4
4
  class ToolCall < Base
5
5
  TYPE = "tool_call"
6
6
 
7
- attr_reader :tool_name, :tool_input, :tool_use_id
7
+ attr_reader :tool_name, :tool_input, :tool_use_id, :timeout
8
8
 
9
9
  # @param content [String] human-readable description of the tool call
10
10
  # @param tool_name [String] registered tool name (e.g. "web_get")
11
11
  # @param tool_input [Hash] arguments passed to the tool
12
12
  # @param tool_use_id [String] Anthropic-assigned ID for correlating call/result
13
+ # @param timeout [Integer] maximum seconds before the call is considered orphaned
13
14
  # @param session_id [String, nil] optional session identifier
14
- def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, session_id: nil)
15
+ def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, timeout: nil, session_id: nil)
15
16
  super(content: content, session_id: session_id)
16
17
  @tool_name = tool_name
17
18
  @tool_input = tool_input
18
19
  @tool_use_id = tool_use_id
20
+ @timeout = timeout
19
21
  end
20
22
 
21
23
  def type
@@ -23,7 +25,7 @@ module Events
23
25
  end
24
26
 
25
27
  def to_h
26
- super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id)
28
+ super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id, timeout: timeout)
27
29
  end
28
30
  end
29
31
  end
data/lib/llm/client.rb CHANGED
@@ -181,12 +181,14 @@ module LLM
181
181
  name = tool_use["name"]
182
182
  id = tool_use["id"]
183
183
  input = tool_use["input"] || {}
184
+ timeout = input["timeout"] || Anima::Settings.tool_timeout
184
185
 
185
186
  log(:debug, "tool_call: #{name}(#{input.to_json})")
186
187
 
187
188
  Events::Bus.emit(Events::ToolCall.new(
188
189
  content: "Calling #{name}", tool_name: name,
189
- tool_input: input, tool_use_id: id, session_id: session_id
190
+ tool_input: input, tool_use_id: id, timeout: timeout,
191
+ session_id: session_id
190
192
  ))
191
193
 
192
194
  result = begin
data/lib/shell_session.rb CHANGED
@@ -45,13 +45,15 @@ class ShellSession
45
45
  # automatically if the previous session died (timeout, crash, etc.).
46
46
  #
47
47
  # @param command [String] bash command to execute
48
+ # @param timeout [Integer, nil] per-call timeout in seconds; overrides
49
+ # Settings.command_timeout when provided
48
50
  # @return [Hash] with :stdout, :stderr, :exit_code keys on success
49
51
  # @return [Hash] with :error key on failure
50
- def run(command)
52
+ def run(command, timeout: nil)
51
53
  @mutex.synchronize do
52
54
  return {error: "Shell session is not running"} if @finalized
53
55
  restart unless @alive
54
- execute_in_pty(command)
56
+ execute_in_pty(command, timeout: timeout)
55
57
  end
56
58
  rescue => error # rubocop:disable Lint/RescueException -- LLM must always get a result hash, never a stack trace
57
59
  {error: "#{error.class}: #{error.message}"}
@@ -227,10 +229,10 @@ class ShellSession
227
229
  end
228
230
  end
229
231
 
230
- def execute_in_pty(command)
232
+ def execute_in_pty(command, timeout: nil)
231
233
  clear_stderr
232
234
  marker = "__ANIMA_#{SecureRandom.hex(8)}__"
233
- timeout = Anima::Settings.command_timeout
235
+ timeout ||= Anima::Settings.command_timeout
234
236
  deadline = monotonic_now + timeout
235
237
 
236
238
  @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
data/lib/tools/base.rb CHANGED
@@ -49,7 +49,8 @@ module Tools
49
49
  def initialize(**) = nil
50
50
 
51
51
  # Execute the tool with the given input.
52
- # @param input [Hash] parsed input matching {.input_schema}
52
+ # @param input [Hash] parsed input matching {.input_schema}. May include
53
+ # a "timeout" key (seconds) for tools that support per-call timeout overrides.
53
54
  # @return [String, Hash] result content; Hash with :error key signals failure
54
55
  def execute(input)
55
56
  raise NotImplementedError, "#{self.class} must implement #execute"
data/lib/tools/bash.rb CHANGED
@@ -27,14 +27,16 @@ module Tools
27
27
  @shell_session = shell_session
28
28
  end
29
29
 
30
- # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
30
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
31
+ # Supports optional "timeout" key (seconds) to override the global
32
+ # command_timeout setting for long-running operations.
31
33
  # @return [String] formatted output with stdout, stderr, and exit code
32
34
  # @return [Hash] with :error key on failure
33
35
  def execute(input)
34
36
  command = input["command"].to_s
35
37
  return {error: "Command cannot be blank"} if command.strip.empty?
36
38
 
37
- result = @shell_session.run(command)
39
+ result = @shell_session.run(command, timeout: input["timeout"])
38
40
  return result if result.key?(:error)
39
41
 
40
42
  format_result(result[:stdout], result[:stderr], result[:exit_code])
@@ -30,16 +30,21 @@ module Tools
30
30
  @tools[tool.tool_name] = tool
31
31
  end
32
32
 
33
- # @return [Array<Hash>] schema array for the Anthropic tools API parameter
33
+ # @return [Array<Hash>] schema array for the Anthropic tools API parameter.
34
+ # Each schema includes an optional `timeout` parameter (seconds) injected
35
+ # by the registry. The agent can override the default per call for
36
+ # long-running operations.
34
37
  def schemas
35
- @tools.values.map(&:schema)
38
+ default = Anima::Settings.tool_timeout
39
+ @tools.values.map { |tool| inject_timeout(tool.schema, default) }
36
40
  end
37
41
 
38
42
  # Execute a tool by name. Classes are instantiated with the registry's
39
43
  # context; instances are called directly.
40
44
  #
41
45
  # @param name [String] registered tool name
42
- # @param input [Hash] tool input parameters
46
+ # @param input [Hash] tool input parameters (may include "timeout" for
47
+ # tools that support per-call timeout overrides)
43
48
  # @return [String, Hash] tool execution result
44
49
  # @raise [UnknownToolError] if no tool is registered with the given name
45
50
  def execute(name, input)
@@ -58,5 +63,19 @@ module Tools
58
63
  def any?
59
64
  @tools.any?
60
65
  end
66
+
67
+ private
68
+
69
+ # Injects an optional `timeout` parameter into the tool's input schema.
70
+ def inject_timeout(schema, default)
71
+ s = schema.deep_dup
72
+ s[:input_schema] ||= {type: "object", properties: {}}
73
+ s[:input_schema][:properties] ||= {}
74
+ s[:input_schema][:properties]["timeout"] = {
75
+ type: "integer",
76
+ description: "Max execution seconds (default: #{default}). Increase for long-running operations."
77
+ }
78
+ s
79
+ end
61
80
  end
62
81
  end
@@ -71,7 +71,9 @@ module Tools
71
71
 
72
72
  # @return [String, nil] owner/repo parsed from +git remote get-url origin+
73
73
  def git_remote_repo
74
- url, _status = Open3.capture2("git", "remote", "get-url", "origin")
74
+ url, status = Open3.capture2("git", "remote", "get-url", "origin", err: File::NULL)
75
+ return unless status.success?
76
+
75
77
  parse_owner_repo(url.strip)
76
78
  rescue Errno::ENOENT
77
79
  nil
data/lib/tools/web_get.rb CHANGED
@@ -27,17 +27,19 @@ module Tools
27
27
  }
28
28
  end
29
29
 
30
- # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
30
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
31
+ # Supports optional "timeout" key (seconds) to override the global
32
+ # web_request_timeout setting.
31
33
  # @return [Hash] `{body: String, content_type: String}` on success
32
34
  # @return [Hash] `{error: String}` on failure
33
35
  def execute(input)
34
- validate_and_fetch(input["url"].to_s)
36
+ validate_and_fetch(input["url"].to_s, timeout: input["timeout"])
35
37
  end
36
38
 
37
39
  private
38
40
 
39
- def validate_and_fetch(url)
40
- timeout = Anima::Settings.web_request_timeout
41
+ def validate_and_fetch(url, timeout: nil)
42
+ timeout ||= Anima::Settings.web_request_timeout
41
43
  scheme = URI.parse(url).scheme
42
44
 
43
45
  unless %w[http https].include?(scheme)
@@ -14,6 +14,11 @@ module TUI
14
14
  # rendered content fall back to existing behavior: tool events aggregate
15
15
  # into counters, messages store role and content.
16
16
  #
17
+ # Entries with event IDs are maintained in ID order (ascending)
18
+ # regardless of arrival order, preventing misordering from race
19
+ # conditions between live broadcasts and viewport replays.
20
+ # Duplicate IDs are deduplicated by updating the existing entry.
21
+ #
17
22
  # Tool counters aggregate per agent turn: a new counter starts when a
18
23
  # tool_call arrives after a message entry. Consecutive tool events
19
24
  # increment the same counter until the next message breaks the chain.
@@ -183,16 +188,79 @@ module TUI
183
188
  event_data.dig("rendered")&.values&.compact&.first
184
189
  end
185
190
 
191
+ # Inserts a rendered entry at the correct chronological position.
192
+ # System prompt entries (no ID) are always placed at position 0.
186
193
  def record_rendered(data, event_type: nil, id: nil)
187
194
  @mutex.synchronize do
188
195
  entry = {type: :rendered, data: data, event_type: event_type, id: id}
189
- @entries << entry
190
- @entries_by_id[id] = entry if id
196
+ insert_ordered(entry)
191
197
  @version += 1
192
198
  end
193
199
  true
194
200
  end
195
201
 
202
+ # Inserts an entry in event-ID order. Entries without an ID are
203
+ # appended. If an entry with the same ID already exists, updates
204
+ # it in-place (deduplication for live/viewport replay races).
205
+ # System prompt entries are always placed at position 0.
206
+ #
207
+ # @param entry [Hash] the entry to insert
208
+ # @return [void]
209
+ def insert_ordered(entry)
210
+ if entry[:event_type] == "system_prompt"
211
+ @entries.unshift(entry)
212
+ return
213
+ end
214
+
215
+ id = entry[:id]
216
+ unless id
217
+ @entries << entry
218
+ return
219
+ end
220
+
221
+ existing = @entries_by_id[id]
222
+ if existing
223
+ existing[:data] = entry[:data] if entry.key?(:data)
224
+ existing[:content] = entry[:content] if entry.key?(:content)
225
+ existing[:event_type] = entry[:event_type] if entry.key?(:event_type)
226
+ return
227
+ end
228
+
229
+ insert_sorted_by_id(entry)
230
+ @entries_by_id[id] = entry
231
+ end
232
+
233
+ # Inserts an entry in sorted order by event ID. Optimized for the
234
+ # common case where events arrive in order (appends without scanning).
235
+ # Entries without IDs (tool counters, etc.) are skipped during the
236
+ # sort scan and don't affect insertion position.
237
+ #
238
+ # @param entry [Hash] entry with a non-nil +:id+
239
+ # @return [void]
240
+ def insert_sorted_by_id(entry)
241
+ id = entry[:id]
242
+
243
+ # Fast path: entry belongs at the end (typical during live streaming)
244
+ last_id = last_entry_id
245
+ if last_id.nil? || last_id < id
246
+ @entries << entry
247
+ return
248
+ end
249
+
250
+ # Out-of-order arrival: insert before the first entry with a higher ID
251
+ insert_pos = @entries.index { |e| e[:id] && e[:id] > id } || @entries.size
252
+ @entries.insert(insert_pos, entry)
253
+ end
254
+
255
+ # Returns the highest event ID in the entries array, scanning from the
256
+ # end for efficiency (entries with IDs are typically at the tail).
257
+ #
258
+ # @return [Integer, nil] the highest event ID, or nil if no entries have IDs
259
+ def last_entry_id
260
+ @entries.reverse_each { |e| return e[:id] if e[:id] }
261
+ nil
262
+ end
263
+
196
264
  def record_tool_call
197
265
  @mutex.synchronize do
198
266
  current = current_tool_counter
@@ -221,12 +289,9 @@ module TUI
221
289
  content = event_data["content"]
222
290
  return false if content.nil?
223
291
 
224
- event_id = event_data["id"]
225
-
226
292
  @mutex.synchronize do
227
- entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id: event_id}
228
- @entries << entry
229
- @entries_by_id[event_id] = entry if event_id
293
+ entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id: event_data["id"]}
294
+ insert_ordered(entry)
230
295
  @version += 1
231
296
  end
232
297
  true
@@ -39,6 +39,10 @@ mcp_response = 60
39
39
  # Web fetch request timeout.
40
40
  web_request = 10
41
41
 
42
+ # Per-tool-call timeout. The agent can override per call via the timeout parameter.
43
+ # Also used as the default deadline for orphan detection in heal_orphaned_tool_calls!.
44
+ tool = 180
45
+
42
46
  # ─── Shell ──────────────────────────────────────────────────────
43
47
 
44
48
  [shell]
@@ -94,6 +98,9 @@ soul = "{{ANIMA_HOME}}/soul.md"
94
98
 
95
99
  [session]
96
100
 
101
+ # View mode for new sessions: "basic", "verbose", or "debug".
102
+ default_view_mode = "basic"
103
+
97
104
  # Regenerate session name every N messages.
98
105
  name_generation_interval = 30
99
106
 
data/templates/soul.md CHANGED
@@ -17,7 +17,7 @@ config, credentials, skills, workflows, agents.
17
17
  Find your source code README:
18
18
 
19
19
  ```bash
20
- cat $(bundle show anima-core)/README.md
20
+ cat $(gem contents anima-core | grep README.md)
21
21
  ```
22
22
 
23
23
  ## What to do first
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anima-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin
@@ -348,6 +348,7 @@ files:
348
348
  - lib/anima/config_migrator.rb
349
349
  - lib/anima/installer.rb
350
350
  - lib/anima/settings.rb
351
+ - lib/anima/spinner.rb
351
352
  - lib/anima/version.rb
352
353
  - lib/credential_store.rb
353
354
  - lib/environment_probe.rb
@@ -356,7 +357,6 @@ files:
356
357
  - lib/events/bounce_back.rb
357
358
  - lib/events/bus.rb
358
359
  - lib/events/subscriber.rb
359
- - lib/events/subscribers/agent_dispatcher.rb
360
360
  - lib/events/subscribers/message_collector.rb
361
361
  - lib/events/subscribers/persister.rb
362
362
  - lib/events/subscribers/subagent_message_router.rb
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Events
4
- module Subscribers
5
- # Reacts to non-pending {Events::UserMessage} emissions by scheduling
6
- # {AgentRequestJob}. This is the event-driven bridge between the
7
- # channel (which emits the intent) and the job (which persists and
8
- # delivers the message).
9
- #
10
- # Pending messages are skipped — they are picked up by the running
11
- # agent loop after it finishes the current turn.
12
- class AgentDispatcher
13
- include Events::Subscriber
14
-
15
- # @param event [Hash] Rails.event notification hash
16
- def emit(event)
17
- payload = event[:payload]
18
- return unless payload.is_a?(Hash)
19
- return unless payload[:type] == "user_message"
20
- return if payload[:status] == Event::PENDING_STATUS
21
-
22
- session_id = payload[:session_id]
23
- return unless session_id
24
-
25
- AgentRequestJob.perform_later(session_id, content: payload[:content])
26
- end
27
- end
28
- end
29
- end