anima-core 1.0.1 → 1.1.0

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +61 -0
  4. data/README.md +202 -116
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +44 -10
  7. data/app/decorators/agent_message_decorator.rb +6 -0
  8. data/app/decorators/event_decorator.rb +41 -7
  9. data/app/decorators/tool_call_decorator.rb +66 -5
  10. data/app/decorators/tool_decorator.rb +57 -0
  11. data/app/decorators/tool_response_decorator.rb +35 -5
  12. data/app/decorators/user_message_decorator.rb +6 -0
  13. data/app/decorators/web_get_tool_decorator.rb +102 -0
  14. data/app/jobs/agent_request_job.rb +95 -20
  15. data/app/jobs/mneme_job.rb +51 -0
  16. data/app/jobs/passive_recall_job.rb +29 -0
  17. data/app/models/concerns/event/broadcasting.rb +18 -0
  18. data/app/models/event.rb +10 -0
  19. data/app/models/goal.rb +27 -0
  20. data/app/models/goal_pinned_event.rb +11 -0
  21. data/app/models/pinned_event.rb +41 -0
  22. data/app/models/session.rb +335 -6
  23. data/app/models/snapshot.rb +76 -0
  24. data/config/initializers/event_subscribers.rb +14 -3
  25. data/config/initializers/fts5_schema_dump.rb +21 -0
  26. data/db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb +5 -0
  27. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  28. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  29. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  30. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  31. data/lib/agent_loop.rb +67 -18
  32. data/lib/analytical_brain/runner.rb +159 -84
  33. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  34. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  35. data/lib/anima/cli.rb +34 -1
  36. data/lib/anima/config_migrator.rb +205 -0
  37. data/lib/anima/installer.rb +13 -130
  38. data/lib/anima/settings.rb +42 -1
  39. data/lib/anima/version.rb +1 -1
  40. data/lib/events/bounce_back.rb +37 -0
  41. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  42. data/lib/events/subscribers/persister.rb +17 -0
  43. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  44. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  45. data/lib/llm/client.rb +99 -14
  46. data/lib/mneme/compressed_viewport.rb +200 -0
  47. data/lib/mneme/l2_runner.rb +138 -0
  48. data/lib/mneme/passive_recall.rb +69 -0
  49. data/lib/mneme/runner.rb +254 -0
  50. data/lib/mneme/search.rb +150 -0
  51. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  52. data/lib/mneme/tools/everything_ok.rb +24 -0
  53. data/lib/mneme/tools/save_snapshot.rb +68 -0
  54. data/lib/mneme.rb +29 -0
  55. data/lib/providers/anthropic.rb +57 -13
  56. data/lib/shell_session.rb +188 -59
  57. data/lib/tasks/fts5.rake +6 -0
  58. data/lib/tools/remember.rb +179 -0
  59. data/lib/tools/spawn_specialist.rb +21 -9
  60. data/lib/tools/spawn_subagent.rb +22 -11
  61. data/lib/tools/subagent_prompts.rb +20 -3
  62. data/lib/tools/think.rb +57 -0
  63. data/lib/tools/web_get.rb +15 -6
  64. data/lib/tui/app.rb +230 -127
  65. data/lib/tui/cable_client.rb +8 -0
  66. data/lib/tui/decorators/base_decorator.rb +165 -0
  67. data/lib/tui/decorators/bash_decorator.rb +20 -0
  68. data/lib/tui/decorators/edit_decorator.rb +19 -0
  69. data/lib/tui/decorators/read_decorator.rb +24 -0
  70. data/lib/tui/decorators/think_decorator.rb +36 -0
  71. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  72. data/lib/tui/decorators/write_decorator.rb +19 -0
  73. data/lib/tui/flash.rb +139 -0
  74. data/lib/tui/formatting.rb +28 -0
  75. data/lib/tui/height_map.rb +93 -0
  76. data/lib/tui/message_store.rb +25 -1
  77. data/lib/tui/performance_logger.rb +90 -0
  78. data/lib/tui/screens/chat.rb +374 -109
  79. data/templates/config.toml +156 -0
  80. metadata +87 -4
  81. data/CHANGELOG.md +0 -79
  82. data/Gemfile +0 -17
  83. data/lib/tools/return_result.rb +0 -81
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+ require "pathname"
5
+
6
+ module Anima
7
+ # Merges new default settings into an existing config.toml without
8
+ # overwriting user-customized values.
9
+ #
10
+ # Preserves the user's formatting and comments by extracting text blocks
11
+ # from the template and appending them to the config file. Missing entire
12
+ # sections are appended with their separator comments; missing keys within
13
+ # existing sections are inserted at the end of the section.
14
+ #
15
+ # @example
16
+ # result = ConfigMigrator.new.run
17
+ # result.status #=> :updated
18
+ # result.additions #=> [#<Addition section="paths" key="soul" value="/home/...">]
19
+ class ConfigMigrator
20
+ ANIMA_HOME = File.expand_path("~/.anima")
21
+ TEMPLATE_PATH = File.expand_path("../../templates/config.toml", __dir__).freeze
22
+
23
+ # A single config key that was added during migration.
24
+ # @!attribute [r] section [String] TOML section name
25
+ # @!attribute [r] key [String] key name within the section
26
+ # @!attribute [r] value [Object] default value from the template
27
+ Addition = Data.define(:section, :key, :value)
28
+
29
+ # Outcome of a migration run.
30
+ # @!attribute [r] status [Symbol] :not_found, :up_to_date, or :updated
31
+ # @!attribute [r] additions [Array<Addition>] keys that were added
32
+ Result = Data.define(:status, :additions)
33
+
34
+ # Section separator pattern used in the template (e.g. "# ─── LLM ───...").
35
+ SEPARATOR_PATTERN = /^# ─── /
36
+
37
+ # @param config_path [String] path to the user's config.toml
38
+ # @param template_path [String] path to the default config template
39
+ # @param anima_home [String] expanded path to ~/.anima (for template interpolation)
40
+ def initialize(config_path: File.join(ANIMA_HOME, "config.toml"),
41
+ template_path: TEMPLATE_PATH,
42
+ anima_home: ANIMA_HOME)
43
+ @config_path = Pathname.new(config_path)
44
+ @template_path = Pathname.new(template_path)
45
+ @anima_home = anima_home
46
+ end
47
+
48
+ # Merge missing settings from the template into the user's config.
49
+ #
50
+ # @return [Result] status (:not_found, :up_to_date, :updated) and additions list
51
+ def run
52
+ return Result.new(status: :not_found, additions: []) unless @config_path.exist?
53
+
54
+ template_text = resolve_template
55
+ template_config = TomlRB.parse(template_text)
56
+ user_config = TomlRB.load_file(@config_path.to_s)
57
+
58
+ additions = find_additions(user_config, template_config)
59
+ return Result.new(status: :up_to_date, additions: []) if additions.empty?
60
+
61
+ apply_additions(additions, template_text)
62
+ Result.new(status: :updated, additions: additions)
63
+ end
64
+
65
+ private
66
+
67
+ # Replace template placeholders with actual paths.
68
+ def resolve_template
69
+ File.read(@template_path.to_s).gsub("{{ANIMA_HOME}}") { @anima_home }
70
+ end
71
+
72
+ # Compare user config against template defaults.
73
+ # Returns additions for keys present in template but absent from user config.
74
+ def find_additions(user, template)
75
+ template.flat_map do |section, keys|
76
+ missing_keys_in_section(user, section, keys)
77
+ end
78
+ end
79
+
80
+ def missing_keys_in_section(user, section, keys)
81
+ keys.filter_map do |key, value|
82
+ next if user.key?(section) && user[section].key?(key)
83
+
84
+ Addition.new(section: section, key: key, value: value)
85
+ end
86
+ end
87
+
88
+ # Write missing settings into the user's config file, preserving existing content.
89
+ def apply_additions(additions, template_text)
90
+ user_text = @config_path.read
91
+ template_blocks = parse_section_blocks(template_text)
92
+
93
+ missing_sections, missing_keys = additions.partition do |addition|
94
+ !user_text.match?(/^\[#{Regexp.escape(addition.section)}\]/)
95
+ end
96
+
97
+ user_text = append_missing_sections(user_text, missing_sections, template_blocks)
98
+ user_text = insert_missing_keys(user_text, missing_keys, template_text)
99
+
100
+ @config_path.write(user_text)
101
+ end
102
+
103
+ def append_missing_sections(user_text, missing_sections, template_blocks)
104
+ missing_sections.map(&:section).uniq.each do |section|
105
+ block = template_blocks[section]
106
+ next unless block
107
+
108
+ user_text = "#{user_text.rstrip}\n\n#{block.rstrip}\n"
109
+ end
110
+ user_text
111
+ end
112
+
113
+ def insert_missing_keys(user_text, missing_keys, template_text)
114
+ missing_keys.each do |addition|
115
+ section = addition.section
116
+ key_block = extract_key_block(template_text, section, addition.key)
117
+ next unless key_block
118
+
119
+ user_text = insert_key_in_section(user_text, section, key_block)
120
+ end
121
+ user_text
122
+ end
123
+
124
+ # Split template into section blocks keyed by TOML section name.
125
+ # Each block spans from its separator comment to the next separator (exclusive).
126
+ def parse_section_blocks(template_text)
127
+ lines = template_text.lines
128
+ separator_indices = lines.each_index.select { |idx| lines[idx].match?(SEPARATOR_PATTERN) }
129
+ block_ranges = build_block_ranges(separator_indices, lines.length)
130
+
131
+ block_ranges.each_with_object({}) do |(start_idx, end_idx), blocks|
132
+ block_lines = lines[start_idx..end_idx]
133
+ section_name = extract_section_name(block_lines)
134
+ blocks[section_name] = block_lines.join if section_name
135
+ end
136
+ end
137
+
138
+ # Find the TOML section name (e.g. "llm") within a block of lines.
139
+ def extract_section_name(block_lines)
140
+ header = block_lines.find { |line| line.match?(/^\[\w+\]/) }
141
+ header&.match(/^\[(\w+)\]/)&.[](1)
142
+ end
143
+
144
+ # Build [start, end] pairs from separator indices.
145
+ def build_block_ranges(separator_indices, total_lines)
146
+ separator_indices.each_with_index.map do |start_idx, position|
147
+ next_pos = position + 1
148
+ end_idx = (next_pos < separator_indices.length) ? separator_indices[next_pos] - 1 : total_lines - 1
149
+ [start_idx, end_idx]
150
+ end
151
+ end
152
+
153
+ # Extract a single key and its preceding comment lines from the template.
154
+ def extract_key_block(template_text, section, key)
155
+ lines = template_text.lines
156
+ in_section = false
157
+
158
+ lines.each_with_index do |line, line_idx|
159
+ if line.match?(/^\[#{Regexp.escape(section)}\]/)
160
+ in_section = true
161
+ elsif line.match?(/^\[/) && in_section
162
+ break
163
+ elsif in_section && line.match?(/^#{Regexp.escape(key)}\s*=/)
164
+ return build_key_block(lines, line_idx)
165
+ end
166
+ end
167
+ nil
168
+ end
169
+
170
+ # Walk backward from a key line to collect preceding comment lines.
171
+ def build_key_block(lines, key_idx)
172
+ comment_start = key_idx
173
+ scan_idx = key_idx - 1
174
+ while scan_idx >= 0 && lines[scan_idx].match?(/^#/)
175
+ comment_start = scan_idx
176
+ scan_idx -= 1
177
+ end
178
+ "\n#{lines[comment_start..key_idx].join}"
179
+ end
180
+
181
+ # Insert a key block at the end of an existing section
182
+ # (before the next separator comment or EOF).
183
+ def insert_key_in_section(user_text, section, key_block)
184
+ lines = user_text.lines
185
+ insert_at = find_section_end(lines, section)
186
+ insert_at -= 1 while insert_at > 0 && lines[insert_at - 1].strip.empty?
187
+
188
+ lines.insert(insert_at, key_block)
189
+ lines.join
190
+ end
191
+
192
+ # Find the line index where a section ends (next separator or section header).
193
+ def find_section_end(lines, section)
194
+ in_section = false
195
+ lines.each_with_index do |line, line_idx|
196
+ if line.match?(/^\[#{Regexp.escape(section)}\]/)
197
+ in_section = true
198
+ elsif in_section && (line.match?(SEPARATOR_PATTERN) || line.match?(/^\[/))
199
+ return line_idx
200
+ end
201
+ end
202
+ lines.length
203
+ end
204
+ end
205
+ end
@@ -75,124 +75,8 @@ module Anima
75
75
  config_path = anima_home.join("config.toml")
76
76
  return if config_path.exist?
77
77
 
78
- config_path.write(<<~TOML)
79
- # Anima Configuration
80
- #
81
- # Edit settings below to customize Anima's behavior.
82
- # Changes take effect immediately — no restart needed.
83
-
84
- # ─── LLM ───────────────────────────────────────────────────────
85
-
86
- [llm]
87
-
88
- # Primary model for conversations.
89
- model = "claude-sonnet-4-20250514"
90
-
91
- # Lightweight model for fast tasks (e.g. session naming).
92
- fast_model = "claude-haiku-4-5"
93
-
94
- # Maximum tokens per LLM response.
95
- max_tokens = 8192
96
-
97
- # Maximum consecutive tool execution rounds per request.
98
- max_tool_rounds = 25
99
-
100
- # Context window budget — tokens reserved for conversation history.
101
- # Set this based on your model's context window minus system prompt.
102
- token_budget = 190_000
103
-
104
- # ─── Timeouts (seconds) ─────────────────────────────────────────
105
-
106
- [timeouts]
107
-
108
- # LLM API request timeout.
109
- api = 30
110
-
111
- # Shell command execution timeout.
112
- command = 30
113
-
114
- # MCP server response timeout.
115
- mcp_response = 60
116
-
117
- # Web fetch request timeout.
118
- web_request = 10
119
-
120
- # ─── Shell ──────────────────────────────────────────────────────
121
-
122
- [shell]
123
-
124
- # Maximum bytes of command output before truncation.
125
- max_output_bytes = 100_000
126
-
127
- # ─── Tools ──────────────────────────────────────────────────────
128
-
129
- [tools]
130
-
131
- # Maximum file size for read/edit operations (bytes).
132
- max_file_size = 10_485_760
133
-
134
- # Maximum lines returned by the read tool.
135
- max_read_lines = 2_000
136
-
137
- # Maximum bytes returned by the read tool.
138
- max_read_bytes = 50_000
139
-
140
- # Maximum bytes from web GET responses.
141
- max_web_response_bytes = 100_000
142
-
143
- # ─── Environment ──────────────────────────────────────────────
144
-
145
- [environment]
146
-
147
- # Files to scan for in the working directory (at root and up to project_files_max_depth subdirectories deep).
148
- project_files = ["CLAUDE.md", "AGENTS.md", "README.md", "CONTRIBUTING.md"]
149
-
150
- # Maximum directory depth for project file scanning.
151
- project_files_max_depth = 3
152
-
153
- # ─── GitHub ─────────────────────────────────────────────────────
154
-
155
- [github]
156
-
157
- # Repository for agent feature requests (owner/repo format).
158
- # Falls back to parsing git remote origin when unset.
159
- repo = "hoblin/anima"
160
-
161
- # Label applied to agent-created feature request issues.
162
- label = "anima-wants"
163
-
164
- # ─── Paths ─────────────────────────────────────────────────────
165
-
166
- [paths]
167
-
168
- # The agent's self-authored identity file.
169
- soul = "#{anima_home.join("soul.md")}"
170
-
171
- # ─── Session ────────────────────────────────────────────────────
172
-
173
- [session]
174
-
175
- # Regenerate session name every N messages.
176
- name_generation_interval = 30
177
-
178
- # ─── Analytical Brain ─────────────────────────────────────────
179
-
180
- [analytical_brain]
181
-
182
- # Maximum tokens per analytical brain response.
183
- # Must accommodate multiple tool calls (rename + goals + skills + ready).
184
- max_tokens = 4096
185
-
186
- # Run the analytical brain synchronously before the main agent on user messages.
187
- # Ensures activated skills are available for the current response.
188
- blocking_on_user_message = true
189
-
190
- # Run the analytical brain asynchronously after the main agent completes.
191
- blocking_on_agent_message = false
192
-
193
- # Number of recent events to include in the analytical brain's context window.
194
- event_window = 20
195
- TOML
78
+ template = File.read(File.join(TEMPLATE_DIR, "config.toml"))
79
+ config_path.write(template.gsub("{{ANIMA_HOME}}") { anima_home.to_s })
196
80
  say " created #{config_path}"
197
81
  end
198
82
 
@@ -231,19 +115,22 @@ module Anima
231
115
 
232
116
  next if key_path.exist? && content_path.exist?
233
117
 
118
+ content_str = content_path.to_s
119
+ key_str = key_path.to_s
120
+
234
121
  key = ActiveSupport::EncryptedFile.generate_key
235
122
  key_path.write(key)
236
- File.chmod(0o600, key_path.to_s)
123
+ File.chmod(0o600, key_str)
237
124
 
238
125
  config = ActiveSupport::EncryptedConfiguration.new(
239
- config_path: content_path.to_s,
240
- key_path: key_path.to_s,
126
+ config_path: content_str,
127
+ key_path: key_str,
241
128
  env_key: "RAILS_MASTER_KEY",
242
129
  raise_if_missing_key: true
243
130
  )
244
131
 
245
132
  config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
246
- File.chmod(0o600, content_path.to_s)
133
+ File.chmod(0o600, content_str)
247
134
  say " created credentials for #{env}"
248
135
  end
249
136
  end
@@ -269,16 +156,12 @@ module Anima
269
156
  WantedBy=default.target
270
157
  UNIT
271
158
 
272
- if service_path.exist?
273
- if service_path.read == unit_content
274
- say " anima.service unchanged"
275
- else
276
- service_path.write(unit_content)
277
- say " updated #{service_path}"
278
- end
159
+ already_exists = service_path.exist?
160
+ if already_exists && service_path.read == unit_content
161
+ say " anima.service unchanged"
279
162
  else
280
163
  service_path.write(unit_content)
281
- say " created #{service_path}"
164
+ say " #{already_exists ? "updated" : "created"} #{service_path}"
282
165
  end
283
166
 
284
167
  system("systemctl", "--user", "daemon-reload", err: File::NULL, out: File::NULL)
@@ -177,6 +177,47 @@ module Anima
177
177
  # @return [Integer]
178
178
  def analytical_brain_event_window = get("analytical_brain", "event_window")
179
179
 
180
+ # ─── Mneme (Memory Department) ────────────────────────────────
181
+
182
+ # Maximum tokens per Mneme LLM response.
183
+ # @return [Integer]
184
+ def mneme_max_tokens = get("mneme", "max_tokens")
185
+
186
+ # Fraction of the main viewport token budget allocated to Mneme's viewport.
187
+ # @return [Float]
188
+ def mneme_viewport_fraction = get("mneme", "viewport_fraction")
189
+
190
+ # Fraction of the main viewport token budget reserved for L1 snapshots.
191
+ # @return [Float]
192
+ def mneme_l1_budget_fraction = get("mneme", "l1_budget_fraction")
193
+
194
+ # Fraction of the main viewport token budget reserved for L2 snapshots.
195
+ # @return [Float]
196
+ def mneme_l2_budget_fraction = get("mneme", "l2_budget_fraction")
197
+
198
+ # Number of uncovered L1 snapshots that triggers L2 compression.
199
+ # @return [Integer]
200
+ def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
201
+
202
+ # Fraction of the main viewport token budget reserved for pinned events.
203
+ # Pinned events appear between snapshots and the sliding window.
204
+ # @return [Float]
205
+ def mneme_pinned_budget_fraction = get("mneme", "pinned_budget_fraction")
206
+
207
+ # ─── Recall (Associative Memory) ────────────────────────────
208
+
209
+ # Maximum search results returned per FTS5 query.
210
+ # @return [Integer]
211
+ def recall_max_results = get("recall", "max_results")
212
+
213
+ # Fraction of the main viewport token budget reserved for recalled memories.
214
+ # @return [Float]
215
+ def recall_budget_fraction = get("recall", "budget_fraction")
216
+
217
+ # Maximum tokens per individual recall snippet.
218
+ # @return [Integer]
219
+ def recall_max_snippet_tokens = get("recall", "max_snippet_tokens")
220
+
180
221
  private
181
222
 
182
223
  # Reads a setting from the config file.
@@ -191,7 +232,7 @@ module Anima
191
232
  value = config.dig(section, key)
192
233
  if value.nil?
193
234
  raise MissingSettingError,
194
- "[#{section}] #{key} is not set in #{config_path}. Run `anima install` to create the config file."
235
+ "[#{section}] #{key} is not set in #{config_path}. Run `anima update` to add missing settings."
195
236
  end
196
237
  value
197
238
  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.1"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -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
@@ -0,0 +1,29 @@
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
@@ -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
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}).
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,15 @@ 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 {AgentRequestJob} inside
67
+ # a transaction with LLM delivery. Pending messages are still
68
+ # auto-persisted here because they queue while the session is busy.
69
+ def persisted_by_job?(event_type, payload)
70
+ event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
71
+ end
55
72
  end
56
73
  end
57
74
  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