anima-core 1.2.0 → 1.4.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
data/lib/anima/cli.rb CHANGED
@@ -39,21 +39,29 @@ module Anima
39
39
  end
40
40
 
41
41
  require_relative "config_migrator"
42
- result = Spinner.run("Migrating configuration...") do
42
+
43
+ result = Spinner.run("Migrating brain configuration...") do
43
44
  Anima::ConfigMigrator.new.run
44
45
  end
45
-
46
- case result.status
47
- when :not_found
46
+ if result.status == :not_found
48
47
  say "Config file not found. Run 'anima install' first.", :red
49
48
  exit 1
50
- when :up_to_date
51
- say " Config is already up to date."
52
- when :updated
53
- result.additions.each do |addition|
54
- say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
55
- end
56
49
  end
50
+ report_migration("Config", result)
51
+
52
+ tui_config_path = File.join(Anima::ConfigMigrator::ANIMA_HOME, "tui.toml")
53
+ tui_template = File.expand_path("../../templates/tui.toml", __dir__)
54
+ unless File.exist?(tui_config_path)
55
+ File.write(tui_config_path, File.read(tui_template))
56
+ say " created #{tui_config_path} (new in this version)"
57
+ end
58
+ tui_result = Spinner.run("Migrating TUI configuration...") do
59
+ Anima::ConfigMigrator.new(
60
+ config_path: tui_config_path,
61
+ template_path: tui_template
62
+ ).run
63
+ end
64
+ report_migration("TUI config", tui_result)
57
65
 
58
66
  restart_service_if_active
59
67
  end
@@ -83,13 +91,15 @@ module Anima
83
91
  end
84
92
 
85
93
  desc "tui", "Launch the Anima terminal interface"
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"
94
+ option :host, desc: "Brain server address (default: from tui.toml or #{DEFAULT_HOST})"
95
+ option :debug, type: :boolean, default: false, desc: "Enable performance logging"
88
96
  def tui
89
97
  require "ratatui_ruby"
98
+ require_relative "../tui/settings"
90
99
  require_relative "../tui/app"
91
100
 
92
- host = options[:host] || DEFAULT_HOST
101
+ TUI::Settings.load!
102
+ host = options[:host] || TUI::Settings.connection_default_host
93
103
 
94
104
  say "Connecting to brain at #{host}...", :cyan
95
105
 
@@ -111,6 +121,24 @@ module Anima
111
121
 
112
122
  private
113
123
 
124
+ # Reports the outcome of a config migration to the user.
125
+ #
126
+ # @param label [String] human-readable config name (e.g. "Config", "TUI config")
127
+ # @param result [Anima::ConfigMigrator::Result] migration outcome
128
+ # @return [void]
129
+ def report_migration(label, result)
130
+ case result.status
131
+ when :not_found
132
+ say "#{label} file not found. Run 'anima install' first.", :red
133
+ when :up_to_date
134
+ say " #{label} is already up to date."
135
+ when :updated
136
+ result.additions.each do |addition|
137
+ say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
138
+ end
139
+ end
140
+ end
141
+
114
142
  # Restarts the systemd user service so updated code takes effect.
115
143
  # Without this, the service continues running the old gem version
116
144
  # until manually restarted (see #269).
@@ -31,6 +31,7 @@ module Anima
31
31
  create_directories
32
32
  create_soul_file
33
33
  create_settings_config
34
+ create_tui_config
34
35
  create_mcp_config
35
36
  generate_credentials
36
37
  create_systemd_service
@@ -68,6 +69,18 @@ module Anima
68
69
  say " created #{config_path}"
69
70
  end
70
71
 
72
+ # Creates ~/.anima/tui.toml — TUI-specific presentation settings.
73
+ # Separate from config.toml because the TUI is a standalone client
74
+ # process with no Rails dependency.
75
+ def create_tui_config
76
+ config_path = anima_home.join("tui.toml")
77
+ return if config_path.exist?
78
+
79
+ template = File.read(File.join(TEMPLATE_DIR, "tui.toml"))
80
+ config_path.write(template)
81
+ say " created #{config_path}"
82
+ end
83
+
71
84
  def create_mcp_config
72
85
  config_path = anima_home.join("mcp.toml")
73
86
  return if config_path.exist?
@@ -117,7 +130,13 @@ module Anima
117
130
  raise_if_missing_key: true
118
131
  )
119
132
 
120
- config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
133
+ config.write(<<~YAML)
134
+ secret_key_base: #{SecureRandom.hex(64)}
135
+ active_record_encryption:
136
+ primary_key: #{SecureRandom.base64(32)}
137
+ deterministic_key: #{SecureRandom.base64(32)}
138
+ key_derivation_salt: #{SecureRandom.base64(32)}
139
+ YAML
121
140
  File.chmod(0o600, content_str)
122
141
  say " created credentials for #{env}"
123
142
  end
@@ -91,6 +91,20 @@ module Anima
91
91
  # @return [Integer]
92
92
  def token_budget = get("llm", "token_budget")
93
93
 
94
+ # Maximum character length for the Think tool's thoughts parameter.
95
+ # Sub-agents receive half this budget (their tasks are less complex).
96
+ # @return [Integer]
97
+ def thinking_budget = get("llm", "thinking_budget")
98
+
99
+ # Model for sub-agent sessions. Sonnet is cost-effective for focused tasks.
100
+ # @return [String] Anthropic model identifier
101
+ def subagent_model = get("llm", "subagent_model")
102
+
103
+ # Context window budget for sub-agent sessions.
104
+ # Smaller than main to keep sub-agents out of the "dumb zone".
105
+ # @return [Integer]
106
+ def subagent_token_budget = get("llm", "subagent_token_budget")
107
+
94
108
  # ─── Timeouts (seconds) ────────────────────────────────────────
95
109
 
96
110
  # LLM API request timeout.
@@ -114,6 +128,13 @@ module Anima
114
128
  # @return [Integer] seconds
115
129
  def tool_timeout = get("timeouts", "tool")
116
130
 
131
+ # Polling interval for user interrupt checks during long-running commands.
132
+ # Enforces a 0.5s floor to prevent busy-polling from misconfiguration.
133
+ # @return [Numeric] seconds (minimum 0.5)
134
+ def interrupt_check_interval
135
+ [get("timeouts", "interrupt_check"), 0.5].max
136
+ end
137
+
117
138
  # ─── Shell ──────────────────────────────────────────────────────
118
139
 
119
140
  # Maximum bytes of command output before truncation.
@@ -126,11 +147,11 @@ module Anima
126
147
  # @return [Integer]
127
148
  def max_file_size = get("tools", "max_file_size")
128
149
 
129
- # Maximum lines returned by the read tool.
150
+ # Maximum lines returned by the read_file tool.
130
151
  # @return [Integer]
131
152
  def max_read_lines = get("tools", "max_read_lines")
132
153
 
133
- # Maximum bytes returned by the read tool.
154
+ # Maximum bytes returned by the read_file tool.
134
155
  # @return [Integer]
135
156
  def max_read_bytes = get("tools", "max_read_bytes")
136
157
 
@@ -142,6 +163,16 @@ module Anima
142
163
  # @return [Integer]
143
164
  def min_web_content_chars = get("tools", "min_web_content_chars")
144
165
 
166
+ # Maximum characters of tool output before head+tail truncation.
167
+ # Full output saved to a temp file for paginated reading.
168
+ # @return [Integer]
169
+ def max_tool_response_chars = get("tools", "max_tool_response_chars")
170
+
171
+ # Maximum characters of sub-agent result before head+tail truncation.
172
+ # Higher than tool threshold because sub-agent output is already curated.
173
+ # @return [Integer]
174
+ def max_subagent_response_chars = get("tools", "max_subagent_response_chars")
175
+
145
176
  # ─── Session ────────────────────────────────────────────────────
146
177
 
147
178
  # View mode applied to new sessions: "basic", "verbose", or "debug".
@@ -228,6 +259,10 @@ module Anima
228
259
  # @return [Integer]
229
260
  def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
230
261
 
262
+ # Fraction of the viewport to evict in batch when Mneme runs.
263
+ # @return [Float]
264
+ def mneme_eviction_fraction = get("mneme", "eviction_fraction")
265
+
231
266
  # Fraction of the main viewport token budget reserved for pinned messages.
232
267
  # Pinned messages appear between snapshots and the sliding window.
233
268
  # @return [Float]
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.2.0"
4
+ VERSION = "1.4.0"
5
5
  end
data/lib/anima.rb CHANGED
@@ -12,7 +12,7 @@ module Anima
12
12
  end
13
13
 
14
14
  # Boots Rails when CLI commands need access to Rails-managed resources
15
- # like encrypted credentials. No-op if Rails is already loaded.
15
+ # like the encrypted secrets table. No-op if Rails is already loaded.
16
16
  def self.boot_rails!
17
17
  return if defined?(Rails)
18
18
 
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Low-level read/write operations on Rails encrypted credentials.
4
- # Wraps the merge-and-write pattern used by {SessionChannel#write_anthropic_token}
5
- # in a reusable helper. All namespacing (e.g. +mcp+, +anthropic+) is the
6
- # caller's responsibility this class operates on raw top-level keys.
3
+ # Read/write operations for runtime secrets (API tokens, MCP credentials).
4
+ # Backed by the {Secret} model with Active Record Encryption — values are
5
+ # encrypted at rest and always fresh (no caching, no file-path issues in
6
+ # forked Solid Queue workers).
7
+ #
8
+ # All namespacing (e.g. +mcp+, +anthropic+) is the caller's responsibility.
7
9
  #
8
10
  # @example Writing a nested credential
9
11
  # CredentialStore.write("mcp", "linear_api_key" => "sk-xxx")
@@ -13,91 +15,40 @@
13
15
  class CredentialStore
14
16
  class << self
15
17
  # Writes one or more key-value pairs under a top-level namespace.
16
- # Merges into existing credentials, preserving sibling keys.
18
+ # Upserts: existing keys are updated, new keys are created.
17
19
  #
18
- # @param namespace [String] top-level YAML key (e.g. "mcp", "anthropic")
20
+ # @param namespace [String] top-level grouping key (e.g. "mcp", "anthropic")
19
21
  # @param pairs [Hash<String, String>] key-value pairs to store
20
22
  # @return [void]
21
23
  def write(namespace, pairs)
22
- existing = load_credentials
23
- section = existing[namespace] ||= {}
24
- section.merge!(pairs)
25
- save_credentials(existing)
24
+ Secret.write(namespace, pairs)
26
25
  end
27
26
 
28
27
  # Reads a single credential value from a namespace.
29
- # Busts the Rails credentials cache first so cross-process writes
30
- # (e.g. token saved in the web process, read in the SolidQueue worker)
31
- # are always visible.
32
28
  #
33
- # @param namespace [String] top-level YAML key
29
+ # @param namespace [String] top-level grouping key
34
30
  # @param key [String] credential key within the namespace
35
31
  # @return [String, nil] credential value or nil if not found
36
32
  def read(namespace, key)
37
- bust_credentials_cache!
38
- Rails.application.credentials.dig(namespace.to_sym, key.to_sym)
33
+ Secret.read(namespace, key)
39
34
  end
40
35
 
41
- # Lists all keys under a namespace.
36
+ # Lists all keys under a namespace (not values).
42
37
  #
43
- # @param namespace [String] top-level YAML key
44
- # @return [Array<String>] credential keys (not values)
38
+ # @param namespace [String] top-level grouping key
39
+ # @return [Array<String>] credential keys
45
40
  def list(namespace)
46
- section = Rails.application.credentials.dig(namespace.to_sym)
47
- return [] unless section.is_a?(Hash)
48
-
49
- section.keys.map(&:to_s)
41
+ Secret.list(namespace)
50
42
  end
51
43
 
52
44
  # Removes a single key from a namespace.
53
45
  # No-op if the key does not exist.
54
46
  #
55
- # @param namespace [String] top-level YAML key
47
+ # @param namespace [String] top-level grouping key
56
48
  # @param key [String] credential key to remove
57
49
  # @return [void]
58
50
  def remove(namespace, key)
59
- existing = load_credentials
60
- section = existing[namespace]
61
- return unless section.is_a?(Hash)
62
- return unless section.key?(key)
63
-
64
- section.delete(key)
65
- existing.delete(namespace) if section.empty?
66
- save_credentials(existing)
67
- end
68
-
69
- private
70
-
71
- # Reads and parses the raw YAML from encrypted credentials.
72
- # Returns an empty hash when the credentials file does not exist yet.
73
- #
74
- # @return [Hash] parsed credentials
75
- def load_credentials
76
- creds = Rails.application.credentials
77
- YAML.safe_load(creds.read) || {}
78
- rescue ActiveSupport::EncryptedFile::MissingContentError
79
- {}
80
- end
81
-
82
- # Writes the full credentials hash back to the encrypted file and
83
- # invalidates the Rails memoization cache so subsequent reads see
84
- # fresh data.
85
- #
86
- # @param data [Hash] complete credentials hash to persist
87
- # @return [void]
88
- def save_credentials(data)
89
- creds = Rails.application.credentials
90
- creds.write(data.to_yaml)
91
- bust_credentials_cache!
92
- end
93
-
94
- # Clears the Rails credentials in-memory cache so the next access
95
- # re-reads and decrypts from disk. Required because Rails memoizes
96
- # the decrypted config in @config per-process.
97
- #
98
- # @return [void]
99
- def bust_credentials_cache!
100
- Rails.application.credentials.instance_variable_set(:@config, nil)
51
+ Secret.remove(namespace, key)
101
52
  end
102
53
  end
103
54
  end
@@ -4,8 +4,22 @@ module Events
4
4
  class AgentMessage < Base
5
5
  TYPE = "agent_message"
6
6
 
7
+ attr_reader :api_metrics
8
+
9
+ # @param content [String] assistant response text
10
+ # @param session_id [Integer, String] session identifier
11
+ # @param api_metrics [Hash, nil] rate limits and usage from API response
12
+ def initialize(content:, session_id: nil, api_metrics: nil)
13
+ super(content: content, session_id: session_id)
14
+ @api_metrics = api_metrics
15
+ end
16
+
7
17
  def type
8
18
  TYPE
9
19
  end
20
+
21
+ def to_h
22
+ super.merge(api_metrics: api_metrics)
23
+ end
10
24
  end
11
25
  end
data/lib/events/base.rb CHANGED
@@ -16,7 +16,7 @@ module Events
16
16
  def initialize(content:, session_id: nil)
17
17
  @content = content
18
18
  @session_id = session_id
19
- @timestamp = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
19
+ @timestamp = Time.current.to_ns
20
20
  end
21
21
 
22
22
  # @return [String] event type identifier
@@ -9,6 +9,11 @@ module Events
9
9
  # session. When initialized without one (global mode), the session is
10
10
  # looked up from the event's session_id payload field.
11
11
  #
12
+ # User messages are NOT persisted here — they are created directly by
13
+ # their callers ({SessionChannel#speak}, {AgentLoop#run}) so the
14
+ # message ID is available for bounce-back cleanup. Pending user
15
+ # messages live in the {PendingMessage} table, outside the event bus.
16
+ #
12
17
  # @example Session-scoped
13
18
  # persister = Events::Subscribers::Persister.new(session)
14
19
  # Events::Bus.subscribe(persister)
@@ -28,10 +33,10 @@ module Events
28
33
 
29
34
  # Receives a Rails.event notification hash and persists it.
30
35
  #
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 {Message::TYPES} (transient events like {Events::BounceBack}).
36
+ # Skips user messages — those are persisted by their callers
37
+ # ({SessionChannel#speak}, {AgentLoop#run}). Also skips event
38
+ # types not in {Message::TYPES} (transient events like
39
+ # {Events::BounceBack}).
35
40
  #
36
41
  # @param event [Hash] with :payload containing event data
37
42
  def emit(event)
@@ -41,7 +46,7 @@ module Events
41
46
  event_type = payload[:type]
42
47
  return if event_type.nil?
43
48
  return unless Message::TYPES.include?(event_type)
44
- return if persisted_by_job?(event_type, payload)
49
+ return if event_type == "user_message"
45
50
 
46
51
  target_session = @session || Session.find_by(id: payload[:session_id])
47
52
  return unless target_session
@@ -50,9 +55,9 @@ module Events
50
55
  target_session.messages.create!(
51
56
  message_type: event_type,
52
57
  payload: payload,
53
- status: payload[:status],
54
58
  tool_use_id: payload[:tool_use_id],
55
- timestamp: payload[:timestamp] || Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
59
+ timestamp: payload[:timestamp] || Time.current.to_ns,
60
+ api_metrics: payload[:api_metrics]
56
61
  )
57
62
  end
58
63
  end
@@ -60,17 +65,6 @@ module Events
60
65
  def session=(new_session)
61
66
  @mutex.synchronize { @session = new_session }
62
67
  end
63
-
64
- private
65
-
66
- # Non-pending user messages are persisted by their callers
67
- # ({SessionChannel#speak}, {AgentLoop#process}) so the message 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] != Message::PENDING_STATUS
73
- end
74
68
  end
75
69
  end
76
70
  end
@@ -14,7 +14,8 @@ module Events
14
14
  #
15
15
  # **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
16
16
  # containing `@name` mentions, the router persists the message in each
17
- # matching child session and wakes them via {AgentRequestJob}.
17
+ # matching child session with a +[from parent]:+ origin label and wakes
18
+ # them via {AgentRequestJob}.
18
19
  #
19
20
  # Both directions delegate to {Session#enqueue_user_message}, which
20
21
  # respects the target session's processing state — persisting directly
@@ -25,9 +26,9 @@ module Events
25
26
  class SubagentMessageRouter
26
27
  include Events::Subscriber
27
28
 
28
- # Attribution prefix format for messages routed from child to parent.
29
- # @example "[sub-agent @loop-sleuth]: Here's what I found..."
30
- ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
29
+ # Origin label for messages routed from parent agent to sub-agent.
30
+ # Lets the sub-agent distinguish delegated work from direct user input.
31
+ PARENT_ATTRIBUTION_FORMAT = "[from parent]: %s"
31
32
 
32
33
  # Regex to extract @mention names from parent agent messages.
33
34
  MENTION_PATTERN = /@(\w[\w-]*)/
@@ -64,7 +65,9 @@ module Events
64
65
  private
65
66
 
66
67
  # Forwards a sub-agent's text message to its parent session
67
- # via {Session#enqueue_user_message}.
68
+ # via {Session#enqueue_user_message} with source metadata.
69
+ # The parent's {PendingMessage} (or idle-path message) owns the
70
+ # attribution formatting — the router passes raw content.
68
71
  #
69
72
  # @param child [Session] the sub-agent session
70
73
  # @param content [String] the sub-agent's message text
@@ -73,13 +76,17 @@ module Events
73
76
  return unless parent
74
77
 
75
78
  name = child.name || "agent-#{child.id}"
76
- attributed = format(ATTRIBUTION_FORMAT, name, content)
79
+ truncated = Tools::ResponseTruncator.truncate(
80
+ content,
81
+ threshold: Anima::Settings.max_subagent_response_chars,
82
+ reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
83
+ )
77
84
 
78
- parent.enqueue_user_message(attributed)
85
+ parent.enqueue_user_message(truncated, source_type: "subagent", source_name: name)
79
86
  end
80
87
 
81
88
  # Scans a parent agent's message for @mentions and routes the message
82
- # to each mentioned child session.
89
+ # to each mentioned child session with origin attribution.
83
90
  #
84
91
  # @param parent [Session] the parent session
85
92
  # @param content [String] the parent agent's message text
@@ -90,11 +97,13 @@ module Events
90
97
  active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
91
98
  return if active_children.empty?
92
99
 
100
+ attributed = format(PARENT_ATTRIBUTION_FORMAT, content)
101
+
93
102
  mentioned_names.each do |name|
94
103
  child = active_children[name]
95
104
  next unless child
96
105
 
97
- child.enqueue_user_message(content)
106
+ child.enqueue_user_message(attributed)
98
107
  end
99
108
  end
100
109
  end
@@ -4,25 +4,14 @@ module Events
4
4
  class UserMessage < Base
5
5
  TYPE = "user_message"
6
6
 
7
- # @return [String, nil] "pending" when queued during active processing, nil otherwise
8
- attr_reader :status
9
-
10
7
  # @param content [String] message text
11
8
  # @param session_id [Integer, nil] session identifier
12
- # @param status [String, nil] "pending" when queued during active agent processing
13
- def initialize(content:, session_id: nil, status: nil)
14
- super(content: content, session_id: session_id)
15
- @status = status
9
+ def initialize(content:, session_id: nil)
10
+ super
16
11
  end
17
12
 
18
13
  def type
19
14
  TYPE
20
15
  end
21
-
22
- def to_h
23
- h = super
24
- h[:status] = status if status
25
- h
26
- end
27
16
  end
28
17
  end