anima-core 1.0.2 → 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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +51 -0
  4. data/README.md +63 -29
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +30 -11
  7. data/app/decorators/tool_call_decorator.rb +32 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +93 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +4 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +402 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/bin/jobs +5 -0
  22. data/config/initializers/event_subscribers.rb +12 -3
  23. data/config/initializers/fts5_schema_dump.rb +21 -0
  24. data/config/queue.yml +0 -1
  25. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  26. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  27. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  28. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  29. data/lib/agent_loop.rb +63 -20
  30. data/lib/analytical_brain/runner.rb +158 -65
  31. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  32. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  33. data/lib/anima/cli.rb +32 -9
  34. data/lib/anima/installer.rb +11 -24
  35. data/lib/anima/settings.rb +59 -0
  36. data/lib/anima/spinner.rb +75 -0
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/environment_probe.rb +4 -4
  39. data/lib/events/bounce_back.rb +37 -0
  40. data/lib/events/subscribers/persister.rb +19 -0
  41. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  42. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  43. data/lib/events/tool_call.rb +5 -3
  44. data/lib/llm/client.rb +19 -9
  45. data/lib/mneme/compressed_viewport.rb +200 -0
  46. data/lib/mneme/l2_runner.rb +138 -0
  47. data/lib/mneme/passive_recall.rb +69 -0
  48. data/lib/mneme/runner.rb +254 -0
  49. data/lib/mneme/search.rb +150 -0
  50. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  51. data/lib/mneme/tools/everything_ok.rb +24 -0
  52. data/lib/mneme/tools/save_snapshot.rb +68 -0
  53. data/lib/mneme.rb +29 -0
  54. data/lib/providers/anthropic.rb +57 -13
  55. data/lib/shell_session.rb +194 -63
  56. data/lib/tasks/fts5.rake +6 -0
  57. data/lib/tools/base.rb +2 -1
  58. data/lib/tools/bash.rb +4 -2
  59. data/lib/tools/registry.rb +22 -3
  60. data/lib/tools/remember.rb +179 -0
  61. data/lib/tools/request_feature.rb +3 -1
  62. data/lib/tools/spawn_specialist.rb +21 -9
  63. data/lib/tools/spawn_subagent.rb +22 -11
  64. data/lib/tools/subagent_prompts.rb +20 -3
  65. data/lib/tools/web_get.rb +21 -10
  66. data/lib/tui/app.rb +222 -125
  67. data/lib/tui/decorators/base_decorator.rb +165 -0
  68. data/lib/tui/decorators/bash_decorator.rb +20 -0
  69. data/lib/tui/decorators/edit_decorator.rb +19 -0
  70. data/lib/tui/decorators/read_decorator.rb +24 -0
  71. data/lib/tui/decorators/think_decorator.rb +36 -0
  72. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  73. data/lib/tui/decorators/write_decorator.rb +19 -0
  74. data/lib/tui/flash.rb +139 -0
  75. data/lib/tui/formatting.rb +28 -0
  76. data/lib/tui/height_map.rb +93 -0
  77. data/lib/tui/message_store.rb +97 -8
  78. data/lib/tui/performance_logger.rb +90 -0
  79. data/lib/tui/screens/chat.rb +358 -133
  80. data/templates/config.toml +47 -0
  81. data/templates/soul.md +1 -1
  82. metadata +83 -4
  83. data/CHANGELOG.md +0 -80
  84. data/Gemfile +0 -17
  85. data/lib/tools/return_result.rb +0 -81
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.
@@ -78,6 +84,7 @@ module Anima
78
84
 
79
85
  desc "tui", "Launch the Anima terminal interface"
80
86
  option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
87
+ option :debug, type: :boolean, default: false, desc: "Enable performance logging to log/tui_performance.log"
81
88
  def tui
82
89
  require "ratatui_ruby"
83
90
  require_relative "../tui/app"
@@ -89,7 +96,7 @@ module Anima
89
96
  cable_client = TUI::CableClient.new(host: host)
90
97
  cable_client.connect
91
98
 
92
- TUI::App.new(cable_client: cable_client).run
99
+ TUI::App.new(cable_client: cable_client, debug: options[:debug]).run
93
100
  end
94
101
 
95
102
  desc "version", "Show version"
@@ -103,5 +110,21 @@ module Anima
103
110
  subcommand "mcp", Mcp
104
111
 
105
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
106
129
  end
107
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?
@@ -115,19 +103,22 @@ module Anima
115
103
 
116
104
  next if key_path.exist? && content_path.exist?
117
105
 
106
+ content_str = content_path.to_s
107
+ key_str = key_path.to_s
108
+
118
109
  key = ActiveSupport::EncryptedFile.generate_key
119
110
  key_path.write(key)
120
- File.chmod(0o600, key_path.to_s)
111
+ File.chmod(0o600, key_str)
121
112
 
122
113
  config = ActiveSupport::EncryptedConfiguration.new(
123
- config_path: content_path.to_s,
124
- key_path: key_path.to_s,
114
+ config_path: content_str,
115
+ key_path: key_str,
125
116
  env_key: "RAILS_MASTER_KEY",
126
117
  raise_if_missing_key: true
127
118
  )
128
119
 
129
120
  config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
130
- File.chmod(0o600, content_path.to_s)
121
+ File.chmod(0o600, content_str)
131
122
  say " created credentials for #{env}"
132
123
  end
133
124
  end
@@ -153,16 +144,12 @@ module Anima
153
144
  WantedBy=default.target
154
145
  UNIT
155
146
 
156
- if service_path.exist?
157
- if service_path.read == unit_content
158
- say " anima.service unchanged"
159
- else
160
- service_path.write(unit_content)
161
- say " updated #{service_path}"
162
- end
147
+ already_exists = service_path.exist?
148
+ if already_exists && service_path.read == unit_content
149
+ say " anima.service unchanged"
163
150
  else
164
151
  service_path.write(unit_content)
165
- say " created #{service_path}"
152
+ say " #{already_exists ? "updated" : "created"} #{service_path}"
166
153
  end
167
154
 
168
155
  system("systemctl", "--user", "daemon-reload", err: File::NULL, out: File::NULL)
@@ -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")
@@ -177,6 +195,47 @@ module Anima
177
195
  # @return [Integer]
178
196
  def analytical_brain_event_window = get("analytical_brain", "event_window")
179
197
 
198
+ # ─── Mneme (Memory Department) ────────────────────────────────
199
+
200
+ # Maximum tokens per Mneme LLM response.
201
+ # @return [Integer]
202
+ def mneme_max_tokens = get("mneme", "max_tokens")
203
+
204
+ # Fraction of the main viewport token budget allocated to Mneme's viewport.
205
+ # @return [Float]
206
+ def mneme_viewport_fraction = get("mneme", "viewport_fraction")
207
+
208
+ # Fraction of the main viewport token budget reserved for L1 snapshots.
209
+ # @return [Float]
210
+ def mneme_l1_budget_fraction = get("mneme", "l1_budget_fraction")
211
+
212
+ # Fraction of the main viewport token budget reserved for L2 snapshots.
213
+ # @return [Float]
214
+ def mneme_l2_budget_fraction = get("mneme", "l2_budget_fraction")
215
+
216
+ # Number of uncovered L1 snapshots that triggers L2 compression.
217
+ # @return [Integer]
218
+ def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
219
+
220
+ # Fraction of the main viewport token budget reserved for pinned events.
221
+ # Pinned events appear between snapshots and the sliding window.
222
+ # @return [Float]
223
+ def mneme_pinned_budget_fraction = get("mneme", "pinned_budget_fraction")
224
+
225
+ # ─── Recall (Associative Memory) ────────────────────────────
226
+
227
+ # Maximum search results returned per FTS5 query.
228
+ # @return [Integer]
229
+ def recall_max_results = get("recall", "max_results")
230
+
231
+ # Fraction of the main viewport token budget reserved for recalled memories.
232
+ # @return [Float]
233
+ def recall_budget_fraction = get("recall", "budget_fraction")
234
+
235
+ # Maximum tokens per individual recall snippet.
236
+ # @return [Integer]
237
+ def recall_max_snippet_tokens = get("recall", "max_snippet_tokens")
238
+
180
239
  private
181
240
 
182
241
  # Reads a setting from the config file.
@@ -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.0.2"
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
 
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Transient failure event emitted when LLM delivery fails inside the
5
+ # Bounce Back transaction. The user event record is rolled back, and
6
+ # this event notifies clients to remove the phantom message and
7
+ # restore the text to the input field.
8
+ #
9
+ # Not persisted — not included in {Event::TYPES}.
10
+ class BounceBack < Base
11
+ TYPE = "bounce_back"
12
+
13
+ # @return [String] human-readable error description
14
+ attr_reader :error
15
+
16
+ # @return [Integer, nil] database ID of the rolled-back event (for client-side removal)
17
+ attr_reader :event_id
18
+
19
+ # @param content [String] original user message text to restore to input
20
+ # @param error [String] error description for the flash message
21
+ # @param session_id [Integer] session the message was intended for
22
+ # @param event_id [Integer, nil] ID of the event that was broadcast optimistically
23
+ def initialize(content:, error:, session_id:, event_id: nil)
24
+ super(content: content, session_id: session_id)
25
+ @error = error
26
+ @event_id = event_id
27
+ end
28
+
29
+ def type
30
+ TYPE
31
+ end
32
+
33
+ def to_h
34
+ super.merge(error: error, event_id: event_id)
35
+ end
36
+ end
37
+ end
@@ -27,6 +27,12 @@ module Events
27
27
  end
28
28
 
29
29
  # Receives a Rails.event notification hash and persists it.
30
+ #
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
+ #
30
36
  # @param event [Hash] with :payload containing event data
31
37
  def emit(event)
32
38
  payload = event[:payload]
@@ -34,6 +40,8 @@ module Events
34
40
 
35
41
  event_type = payload[:type]
36
42
  return if event_type.nil?
43
+ return unless Event::TYPES.include?(event_type)
44
+ return if persisted_by_job?(event_type, payload)
37
45
 
38
46
  target_session = @session || Session.find_by(id: payload[:session_id])
39
47
  return unless target_session
@@ -52,6 +60,17 @@ module Events
52
60
  def session=(new_session)
53
61
  @mutex.synchronize { @session = new_session }
54
62
  end
63
+
64
+ private
65
+
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.
71
+ def persisted_by_job?(event_type, payload)
72
+ event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
73
+ end
55
74
  end
56
75
  end
57
76
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Routes text messages between parent and child sessions, enabling
6
+ # bidirectional @mention communication.
7
+ #
8
+ # **Child → Parent:** When a sub-agent emits an {Events::AgentMessage},
9
+ # the router persists a {Events::UserMessage} in the parent session
10
+ # with attribution prefix, then wakes the parent via {AgentRequestJob}.
11
+ #
12
+ # **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
13
+ # containing `@name` mentions, the router persists the message in each
14
+ # matching child session and wakes them via {AgentRequestJob}.
15
+ #
16
+ # Both directions use direct persistence + job enqueue (same pattern as
17
+ # {Tools::SpawnSubagent#spawn_child}) to avoid conflicts with the global
18
+ # {Persister} which skips non-pending user messages.
19
+ #
20
+ # This replaces the +return_result+ tool — sub-agents communicate
21
+ # through natural text messages instead of structured tool calls.
22
+ class SubagentMessageRouter
23
+ include Events::Subscriber
24
+
25
+ # Attribution prefix format for messages routed from child to parent.
26
+ # @example "[sub-agent @loop-sleuth]: Here's what I found..."
27
+ ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
28
+
29
+ # Regex to extract @mention names from parent agent messages.
30
+ MENTION_PATTERN = /@(\w[\w-]*)/
31
+
32
+ # Routes agent text messages between parent and child sessions.
33
+ #
34
+ # For sub-agent sessions: forwards to parent with attribution prefix.
35
+ # For parent sessions: scans for @mentions and routes to matching children.
36
+ #
37
+ # @param event [Hash] Rails.event notification hash with +:payload+ containing
38
+ # an +agent_message+ event (type, session_id, content)
39
+ # @return [void]
40
+ def emit(event)
41
+ payload = event[:payload]
42
+ return unless payload.is_a?(Hash)
43
+ return unless payload[:type] == "agent_message"
44
+
45
+ session_id = payload[:session_id]
46
+ return unless session_id
47
+
48
+ content = payload[:content].to_s
49
+ return if content.empty?
50
+
51
+ session = Session.find_by(id: session_id)
52
+ return unless session
53
+
54
+ if session.sub_agent?
55
+ route_to_parent(session, content)
56
+ else
57
+ route_mentions_to_children(session, content)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Forwards a sub-agent's text message to its parent session.
64
+ # Persists directly and enqueues a job so the parent agent wakes
65
+ # up to process the message.
66
+ #
67
+ # @param child [Session] the sub-agent session
68
+ # @param content [String] the sub-agent's message text
69
+ def route_to_parent(child, content)
70
+ parent = child.parent_session
71
+ return unless parent
72
+
73
+ name = child.name || "agent-#{child.id}"
74
+ attributed = format(ATTRIBUTION_FORMAT, name, content)
75
+
76
+ parent.create_user_event(attributed)
77
+ AgentRequestJob.perform_later(parent.id)
78
+ end
79
+
80
+ # Scans a parent agent's message for @mentions and routes the message
81
+ # to each mentioned child session.
82
+ #
83
+ # @param parent [Session] the parent session
84
+ # @param content [String] the parent agent's message text
85
+ def route_mentions_to_children(parent, content)
86
+ mentioned_names = content.scan(MENTION_PATTERN).flatten.uniq
87
+ return if mentioned_names.empty?
88
+
89
+ active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
90
+ return if active_children.empty?
91
+
92
+ mentioned_names.each do |name|
93
+ child = active_children[name]
94
+ next unless child
95
+
96
+ child.create_user_event(content)
97
+ AgentRequestJob.perform_later(child.id)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Bridges transient (non-persisted) events to ActionCable so clients
6
+ # receive them over WebSocket. Persisted events reach clients via
7
+ # {Event::Broadcasting} callbacks; this subscriber handles events
8
+ # that never touch the database.
9
+ #
10
+ # @example Registering at boot
11
+ # Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
12
+ class TransientBroadcaster
13
+ include Events::Subscriber
14
+
15
+ # Event types that are broadcast without persistence.
16
+ TRANSIENT_TYPES = [Events::BounceBack::TYPE].freeze
17
+
18
+ # @param event [Hash] Rails.event notification hash
19
+ def emit(event)
20
+ payload = event[:payload]
21
+ return unless payload.is_a?(Hash)
22
+
23
+ event_type = payload[:type]
24
+ return unless TRANSIENT_TYPES.include?(event_type)
25
+
26
+ session_id = payload[:session_id]
27
+ return unless session_id
28
+
29
+ ActionCable.server.broadcast(
30
+ "session_#{session_id}",
31
+ payload.transform_keys(&:to_s)
32
+ )
33
+ end
34
+ end
35
+ end
36
+ 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
@@ -70,10 +70,13 @@ module LLM
70
70
  # @param messages [Array<Hash>] conversation messages in Anthropic format
71
71
  # @param registry [Tools::Registry] registered tools to make available
72
72
  # @param session_id [Integer, String] session ID for emitted events
73
+ # @param first_response [Hash, nil] pre-fetched first API response from
74
+ # {AgentLoop#deliver!}. Skips the first API call when provided so
75
+ # the Bounce Back transaction doesn't duplicate work.
73
76
  # @param options [Hash] additional API parameters (e.g. +system:+)
74
77
  # @return [String, nil] the assistant's final text response, or nil when interrupted
75
78
  # @raise [Providers::Anthropic::Error] on API errors
76
- def chat_with_tools(messages, registry:, session_id:, **options)
79
+ def chat_with_tools(messages, registry:, session_id:, first_response: nil, **options)
77
80
  messages = messages.dup
78
81
  rounds = 0
79
82
 
@@ -84,13 +87,17 @@ module LLM
84
87
  return "[Tool loop exceeded #{max_rounds} rounds — halting]"
85
88
  end
86
89
 
87
- response = provider.create_message(
88
- model: model,
89
- messages: messages,
90
- max_tokens: max_tokens,
91
- tools: registry.schemas,
92
- **options
93
- )
90
+ response = if first_response && rounds == 1
91
+ first_response
92
+ else
93
+ provider.create_message(
94
+ model: model,
95
+ messages: messages,
96
+ max_tokens: max_tokens,
97
+ tools: registry.schemas,
98
+ **options
99
+ )
100
+ end
94
101
 
95
102
  log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
96
103
 
@@ -174,12 +181,14 @@ module LLM
174
181
  name = tool_use["name"]
175
182
  id = tool_use["id"]
176
183
  input = tool_use["input"] || {}
184
+ timeout = input["timeout"] || Anima::Settings.tool_timeout
177
185
 
178
186
  log(:debug, "tool_call: #{name}(#{input.to_json})")
179
187
 
180
188
  Events::Bus.emit(Events::ToolCall.new(
181
189
  content: "Calling #{name}", tool_name: name,
182
- 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
183
192
  ))
184
193
 
185
194
  result = begin
@@ -189,6 +198,7 @@ module LLM
189
198
  {error: "#{error.class}: #{error.message}"}
190
199
  end
191
200
 
201
+ result = ToolDecorator.call(name, result)
192
202
  result_content = format_tool_result(result)
193
203
  log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
194
204