anima-core 1.3.0 → 1.5.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -1,199 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Executes an LLM agent loop as a background job with retry logic
4
- # for transient failures (network errors, rate limits, server errors).
5
- #
6
- # Supports two modes:
7
- #
8
- # **Immediate Persist (message_id provided):** The user message 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
- # message is deleted and a {Events::BounceBack} is emitted so clients
12
- # can restore the text to the input field.
13
- #
14
- # **Standard (no message_id):** Processes already-persisted messages (e.g.
15
- # after pending message promotion). Uses ActiveJob retry/discard for
16
- # error handling.
17
- #
18
- # @example Immediate Persist — message already saved by SessionChannel
19
- # AgentRequestJob.perform_later(session.id, message_id: 42)
20
- #
21
- # @example Standard — pending message processing
22
- # AgentRequestJob.perform_later(session.id)
23
- class AgentRequestJob < ApplicationJob
24
- queue_as :default
25
-
26
- # ActionCable action signaling clients to prompt for an API token.
27
- AUTH_REQUIRED_ACTION = "authentication_required"
28
-
29
- # Standard path only — immediate persist handles its own errors.
30
- retry_on Providers::Anthropic::TransientError,
31
- wait: :polynomially_longer, attempts: 5 do |job, error|
32
- Events::Bus.emit(Events::SystemMessage.new(
33
- content: "Failed after multiple retries: #{error.message}",
34
- session_id: job.arguments.first
35
- ))
36
- end
37
-
38
- discard_on ActiveRecord::RecordNotFound
39
- discard_on Providers::Anthropic::AuthenticationError do |job, error|
40
- session_id = job.arguments.first
41
- Events::Bus.emit(Events::SystemMessage.new(
42
- content: "Authentication failed: #{error.message}",
43
- session_id: session_id
44
- ))
45
- ActionCable.server.broadcast(
46
- "session_#{session_id}",
47
- {"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
48
- )
49
- end
50
-
51
- # @param session_id [Integer] ID of the session to process
52
- # @param message_id [Integer, nil] ID of a pre-persisted user message (triggers delivery verification)
53
- def perform(session_id, message_id: nil)
54
- session = Session.find(session_id)
55
-
56
- # Atomic: only one job processes a session at a time.
57
- return unless claim_processing(session_id)
58
-
59
- run_analytical_brain_blocking(session)
60
-
61
- agent_loop = AgentLoop.new(session: session)
62
-
63
- if message_id
64
- deliver_persisted_message(session, message_id, agent_loop)
65
- else
66
- agent_loop.run
67
- end
68
-
69
- # Process any pending messages queued while we were busy.
70
- loop do
71
- promoted = session.promote_pending_messages!
72
- break if promoted == 0
73
- agent_loop.run
74
- end
75
-
76
- session.schedule_analytical_brain!
77
- ensure
78
- release_processing(session_id)
79
- clear_interrupt(session_id)
80
- agent_loop&.finalize
81
- end
82
-
83
- private
84
-
85
- # Verifies LLM delivery for a pre-persisted user message.
86
- #
87
- # The message 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 message 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.
95
- #
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).
103
- #
104
- # @param session [Session] the conversation session
105
- # @param message_id [Integer] database ID of the pre-persisted user message
106
- # @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
107
- def deliver_persisted_message(session, message_id, agent_loop)
108
- message = Message.find_by(id: message_id, session_id: session.id)
109
- # Message 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 message
113
-
114
- content = message.payload["content"]
115
-
116
- begin
117
- agent_loop.deliver!
118
- rescue => error
119
- message.destroy!
120
- Events::Bus.emit(Events::BounceBack.new(
121
- content: content,
122
- error: error.message,
123
- session_id: session.id,
124
- message_id: message_id
125
- ))
126
- broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
127
- return
128
- end
129
-
130
- agent_loop.run
131
- end
132
-
133
- def broadcast_auth_required(session_id, error)
134
- ActionCable.server.broadcast(
135
- "session_#{session_id}",
136
- {"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
137
- )
138
- end
139
-
140
- # Runs the analytical brain synchronously before the main agent loop.
141
- # Respects the blocking_on_user_message setting and session guards
142
- # (skips sub-agents and sessions with too few messages).
143
- def run_analytical_brain_blocking(session)
144
- return unless Anima::Settings.analytical_brain_blocking_on_user_message
145
- return if session.sub_agent?
146
-
147
- AnalyticalBrain::Runner.new(session).call
148
- rescue => error
149
- # The analytical brain is best-effort: skill activation enhances the
150
- # response but the main agent must still reply even if it fails.
151
- msg = "FAILED (blocking) session=#{session.id}: #{error.class}: #{error.message}"
152
- Rails.logger.error("Analytical brain #{msg}")
153
- AnalyticalBrain.logger.error("#{msg}\n#{error.backtrace&.first(10)&.join("\n")}")
154
- end
155
-
156
- # Sets the session's processing flag atomically. Returns true if this
157
- # job claimed the lock, false if another job already holds it.
158
- # Broadcasts +session_state: llm_generating+ and the state change to
159
- # the parent session's HUD.
160
- def claim_processing(session_id)
161
- claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
162
- if claimed
163
- session = Session.find_by(id: session_id)
164
- session&.broadcast_session_state("llm_generating")
165
- session&.broadcast_children_update_to_parent
166
- end
167
- claimed
168
- end
169
-
170
- # Clears the processing flag so the session can accept new jobs.
171
- # Broadcasts +session_state: idle+ to the session stream (replaces
172
- # the old +processing_stopped+ action) and +children_updated+ to the
173
- # parent session's HUD.
174
- def release_processing(session_id)
175
- Session.where(id: session_id).update_all(processing: false)
176
- session = Session.find_by(id: session_id)
177
- session&.broadcast_session_state("idle")
178
- session&.broadcast_children_update_to_parent
179
- end
180
-
181
- # Safety-net clearing of the interrupt flag.
182
- def clear_interrupt(session_id)
183
- Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
184
- end
185
-
186
- # Emits a system message before each retry so the user sees
187
- # "retrying..." instead of nothing.
188
- def retry_job(options = {})
189
- error = options[:error]
190
- wait = options[:wait]
191
-
192
- Events::Bus.emit(Events::SystemMessage.new(
193
- content: "#{error.message} — retrying in #{wait.to_i}s...",
194
- session_id: arguments.first
195
- ))
196
-
197
- super
198
- end
199
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Runs the analytical brain — a phantom LLM loop that observes the main
4
- # session and performs background maintenance (currently: session naming).
5
- #
6
- # Replaces {GenerateSessionNameJob} with a tool-based architecture that
7
- # future tickets will expand with skill activation, goal tracking, etc.
8
- #
9
- # Scheduling guards live in {Session#schedule_analytical_brain!} — this
10
- # job always runs when called.
11
- #
12
- # @example
13
- # AnalyticalBrainJob.perform_later(session.id)
14
- class AnalyticalBrainJob < ApplicationJob
15
- queue_as :default
16
-
17
- retry_on Providers::Anthropic::TransientError,
18
- wait: :polynomially_longer, attempts: 3
19
-
20
- discard_on ActiveRecord::RecordNotFound
21
- discard_on Providers::Anthropic::AuthenticationError
22
-
23
- # @param session_id [Integer] the main Session to analyze
24
- def perform(session_id)
25
- brain_log = AnalyticalBrain.logger
26
- session = Session.find(session_id)
27
- brain_log.info("async job started for session=#{session_id}")
28
- AnalyticalBrain::Runner.new(session).call
29
- rescue => error
30
- brain_log.error("FAILED (async) session=#{session_id}: #{error.class}: #{error.message}")
31
- raise
32
- end
33
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Counts tokens in a message's payload via the Anthropic API and
4
- # caches the result on the message record. Enqueued automatically
5
- # after each LLM message is created.
6
- class CountMessageTokensJob < ApplicationJob
7
- queue_as :default
8
-
9
- retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
10
- discard_on ActiveRecord::RecordNotFound
11
-
12
- # @param message_id [Integer] the Message record to count tokens for
13
- def perform(message_id)
14
- message = Message.find(message_id)
15
- return if already_counted?(message)
16
-
17
- provider = Providers::Anthropic.new
18
- api_messages = [{role: message.api_role, content: message.payload["content"].to_s}]
19
-
20
- token_count = provider.count_tokens(
21
- model: Anima::Settings.model,
22
- messages: api_messages
23
- )
24
-
25
- # Guard against parallel jobs: reload and re-check before writing.
26
- # Uses update! (not update_all) so {Message::Broadcasting} after_update_commit
27
- # broadcasts the updated token count to connected clients.
28
- message.reload
29
- return if already_counted?(message)
30
-
31
- message.update!(token_count: token_count)
32
- end
33
-
34
- private
35
-
36
- def already_counted?(message)
37
- message.token_count > 0
38
- end
39
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Runs passive recall after goal updates — searches message history for
4
- # context relevant to active goals and caches results on the session
5
- # for viewport injection.
6
- #
7
- # Idempotent: multiple enqueues for the same session safely overwrite
8
- # each other's results — last one wins.
9
- #
10
- # @example
11
- # PassiveRecallJob.perform_later(session.id)
12
- class PassiveRecallJob < ApplicationJob
13
- queue_as :default
14
-
15
- discard_on ActiveRecord::RecordNotFound
16
-
17
- # @param session_id [Integer]
18
- def perform(session_id)
19
- session = Session.find(session_id)
20
- results = Mneme::PassiveRecall.new(session).call
21
-
22
- if results.any?
23
- session.update_column(:recalled_message_ids, results.map(&:message_id))
24
- Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
25
- elsif session.recalled_message_ids.present?
26
- session.update_column(:recalled_message_ids, [])
27
- end
28
- end
29
- end
@@ -1,85 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Broadcasts Message records to connected WebSocket clients via ActionCable.
4
- # Follows the Turbo Streams pattern: messages are broadcast on both create
5
- # and update, with an action type so clients can distinguish append from
6
- # replace operations.
7
- #
8
- # Each broadcast includes the Message's database ID, enabling clients to
9
- # maintain an ID-indexed store for efficient in-place updates (e.g. when
10
- # token counts arrive asynchronously from {CountMessageTokensJob}).
11
- #
12
- # When a new message pushes old messages out of the LLM's context window,
13
- # the broadcast includes `evicted_message_ids` so clients can remove
14
- # phantom messages that the agent no longer knows about.
15
- #
16
- # @example Create broadcast payload
17
- # {
18
- # "type" => "user_message", "content" => "hello", ...,
19
- # "id" => 42, "action" => "create",
20
- # "rendered" => { "basic" => { "role" => "user", "content" => "hello" } }
21
- # }
22
- #
23
- # @example Broadcast with viewport evictions
24
- # {
25
- # "type" => "agent_message", "content" => "...", ...,
26
- # "id" => 99, "action" => "create",
27
- # "evicted_message_ids" => [101, 102, 103]
28
- # }
29
- #
30
- # @example Update broadcast payload (e.g. token count arrives)
31
- # {
32
- # "type" => "user_message", "content" => "hello", ...,
33
- # "id" => 42, "action" => "update",
34
- # "rendered" => { "debug" => { "role" => "user", "content" => "hello", "tokens" => 15 } }
35
- # }
36
- module Message::Broadcasting
37
- extend ActiveSupport::Concern
38
-
39
- ACTION_CREATE = "create"
40
- ACTION_UPDATE = "update"
41
-
42
- included do
43
- after_create_commit :broadcast_create
44
- after_update_commit :broadcast_update
45
- end
46
-
47
- private
48
-
49
- def broadcast_create
50
- broadcast_message(action: ACTION_CREATE)
51
- end
52
-
53
- def broadcast_update
54
- broadcast_message(action: ACTION_UPDATE)
55
- end
56
-
57
- # Decorates the message for the session's current view mode and broadcasts
58
- # the payload to the session's ActionCable stream. Includes viewport
59
- # eviction metadata so clients can remove messages the LLM has forgotten.
60
- #
61
- # @param action [String] ACTION_CREATE or ACTION_UPDATE — tells clients how to handle the message
62
- def broadcast_message(action:)
63
- return unless session_id
64
-
65
- session = Session.find_by(id: session_id)
66
- return unless session
67
-
68
- mode = session.view_mode
69
- decorator = MessageDecorator.for(self)
70
- broadcast_payload = payload.merge("id" => id, "action" => action)
71
-
72
- if decorator
73
- broadcast_payload["rendered"] = {mode => decorator.render(mode)}
74
- end
75
-
76
- evicted_ids = session.recalculate_viewport!
77
- broadcast_payload["evicted_message_ids"] = evicted_ids if evicted_ids.any?
78
-
79
- # The nil? branch fires on every broadcast until boundary initializes, but
80
- # schedule_mneme! returns early after setting the boundary — cost is one DB read + write.
81
- session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_message_id.nil?
82
-
83
- ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
84
- end
85
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Skip FTS5 virtual tables during Ruby schema dump.
4
- # Rails' schema dumper can't express contentless FTS5 tables — the
5
- # migration handles creation. The schema.rb omits the virtual table,
6
- # and db:prepare runs pending migrations to recreate it.
7
- ActiveSupport.on_load(:active_record) do
8
- require "active_record/connection_adapters/sqlite3/schema_dumper"
9
-
10
- ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(Module.new do
11
- private
12
-
13
- def virtual_tables(stream)
14
- # Intentionally empty — FTS5 tables are managed by migrations.
15
- # The default implementation crashes on contentless FTS5 arguments.
16
- end
17
- end)
18
- rescue LoadError
19
- # Not using SQLite3 adapter — nothing to patch.
20
- nil
21
- end
data/lib/agent_loop.rb DELETED
@@ -1,186 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Orchestrates the LLM agent loop: accepts user input, runs the tool-use
4
- # cycle via {LLM::Client}, and emits events through {Events::Bus}.
5
- #
6
- # Extracted from {TUI::Screens::Chat} so the same agent logic can run from
7
- # the TUI, a background job, or an Action Cable channel.
8
- #
9
- # @note Not thread-safe. Callers must serialize concurrent access
10
- # (e.g. {AgentRequestJob} uses session-level processing locks).
11
- #
12
- # @example Basic usage
13
- # loop = AgentLoop.new(session: session)
14
- # loop.run
15
- # loop.finalize
16
- #
17
- # @example With dependency injection (testing)
18
- # loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
19
- # loop.run
20
- class AgentLoop
21
- # @return [Session] the conversation session this loop operates on
22
- attr_reader :session
23
-
24
- # @param session [Session] the conversation session
25
- # @param shell_session [ShellSession, nil] injectable persistent shell;
26
- # created automatically if not provided
27
- # @param client [LLM::Client, nil] injectable LLM client;
28
- # created lazily on first {#run} call if not provided
29
- # @param registry [Tools::Registry, nil] injectable tool registry;
30
- # built lazily on first {#run} call if not provided
31
- def initialize(session:, shell_session: nil, client: nil, registry: nil)
32
- @session = session
33
- @shell_session = shell_session || ShellSession.new(session_id: session.id)
34
- @client = client
35
- @registry = registry
36
- end
37
-
38
- # Makes the first LLM API call to verify delivery. Called inside the
39
- # Bounce Back transaction — if this raises, the user event rolls back.
40
- #
41
- # Caches the first response so the subsequent {#run} call can continue
42
- # from it without duplicating the API call.
43
- #
44
- # @return [void]
45
- # @raise [Providers::Anthropic::Error] on any LLM delivery failure
46
- def deliver!
47
- @client ||= LLM::Client.new
48
- @registry ||= build_tool_registry
49
-
50
- messages = @session.messages_for_llm
51
- options = build_llm_options
52
-
53
- @first_response = @client.provider.create_message(
54
- model: @client.model,
55
- messages: messages,
56
- max_tokens: @client.max_tokens,
57
- tools: @registry.schemas,
58
- **options
59
- )
60
- end
61
-
62
- # Runs the LLM tool-use loop on persisted session messages.
63
- #
64
- # When a cached first response exists (from {#deliver!}), continues
65
- # from that response without a redundant API call. Otherwise makes
66
- # a fresh call — used for pending message processing and the standard
67
- # path.
68
- #
69
- # Lets errors propagate — designed for callers like {AgentRequestJob}
70
- # that handle retries and need errors to bubble up.
71
- #
72
- # @return [String, nil] the agent's response text, or nil when interrupted
73
- # @raise [Providers::Anthropic::TransientError] on retryable network/server errors
74
- # @raise [Providers::Anthropic::AuthenticationError] on auth failures
75
- def run
76
- @client ||= LLM::Client.new
77
- @registry ||= build_tool_registry
78
-
79
- messages = @session.messages_for_llm
80
- options = build_llm_options
81
-
82
- first_resp = @first_response
83
- @first_response = nil
84
-
85
- response = @client.chat_with_tools(
86
- messages, registry: @registry, session_id: @session.id,
87
- first_response: first_resp, **options
88
- )
89
- return unless response
90
-
91
- Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
92
- response
93
- end
94
-
95
- # Clean up the underlying {ShellSession} PTY and resources.
96
- # Safe to call multiple times — subsequent calls are no-ops.
97
- def finalize
98
- @shell_session&.finalize
99
- end
100
-
101
- # Tool classes available to all sessions by default.
102
- # @return [Array<Class<Tools::Base>>]
103
- STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember, Tools::Recall].freeze
104
-
105
- # Tools that bypass {Session#granted_tools} filtering.
106
- # The agent's reasoning depends on these regardless of task scope.
107
- # @return [Array<Class<Tools::Base>>]
108
- ALWAYS_GRANTED_TOOLS = [Tools::Think].freeze
109
-
110
- # Name-to-class mapping for tool restriction validation and registry building.
111
- # @return [Hash{String => Class<Tools::Base>}]
112
- STANDARD_TOOLS_BY_NAME = STANDARD_TOOLS.index_by(&:tool_name).freeze
113
-
114
- private
115
-
116
- # Assembles LLM options (system prompt, environment context).
117
- # Broadcasts the full debug context (system prompt + tool schemas)
118
- # to debug-mode TUI clients on every LLM request.
119
- # @return [Hash] options for {LLM::Client#chat_with_tools}
120
- def build_llm_options
121
- options = {}
122
- unless @session.sub_agent?
123
- env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
124
- end
125
- prompt = @session.system_prompt(environment_context: env_context)
126
- options[:system] = prompt if prompt
127
- @session.broadcast_debug_context(system: prompt, tools: @registry&.schemas)
128
- options
129
- end
130
-
131
- # Builds the tool registry appropriate for this session type.
132
- # Main sessions get standard tools + spawn_subagent + spawn_specialist.
133
- # Sub-agents get granted standard tools only (no spawning, no nesting).
134
- # Sub-agent results are delivered through natural text messages routed
135
- # by {Events::Subscribers::SubagentMessageRouter}.
136
- # When {Session#granted_tools} is nil, all standard tools are granted.
137
- # MCP tools from configured servers are registered for all session types.
138
- #
139
- # @return [Tools::Registry] registry with available tools
140
- def build_tool_registry
141
- context = {shell_session: @shell_session, session: @session}
142
- registry = Tools::Registry.new(context: context)
143
-
144
- granted_standard_tools.each { |tool| registry.register(tool) }
145
-
146
- if @session.sub_agent?
147
- registry.register(Tools::MarkGoalCompleted)
148
- else
149
- registry.register(Tools::SpawnSubagent)
150
- registry.register(Tools::SpawnSpecialist)
151
- registry.register(Tools::OpenIssue)
152
- end
153
-
154
- register_mcp_tools(registry)
155
-
156
- registry
157
- end
158
-
159
- # Loads tools from configured MCP servers and adds them to the registry.
160
- # Warnings are emitted as system messages — visible to both the user
161
- # (in verbose mode) and the LLM (via CONTEXT_TYPES) so the agent can
162
- # explain config issues instead of guessing.
163
- #
164
- # @param registry [Tools::Registry] the registry to add MCP tools to
165
- # @return [void]
166
- def register_mcp_tools(registry)
167
- warnings = Mcp::ClientManager.new.register_tools(registry)
168
- warnings.each do |message|
169
- Events::Bus.emit(Events::SystemMessage.new(content: message, session_id: @session.id))
170
- end
171
- end
172
-
173
- # Standard tools available to this session.
174
- # Returns all when {Session#granted_tools} is nil (no restriction).
175
- # Returns only matching tools when granted_tools is an array,
176
- # always including {ALWAYS_GRANTED_TOOLS}.
177
- #
178
- # @return [Array<Class<Tools::Base>>] tool classes to register
179
- def granted_standard_tools
180
- granted = @session.granted_tools
181
- return STANDARD_TOOLS unless granted
182
-
183
- explicitly_granted = granted.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
184
- (ALWAYS_GRANTED_TOOLS + explicitly_granted).uniq
185
- end
186
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AnalyticalBrain
4
- module Tools
5
- # Deactivates a domain knowledge skill on the main session.
6
- # The skill's content is removed from the main agent's system prompt.
7
- class DeactivateSkill < ::Tools::Base
8
- def self.tool_name = "deactivate_skill"
9
-
10
- def self.description = "Remove domain knowledge that is no longer relevant."
11
-
12
- def self.input_schema
13
- {
14
- type: "object",
15
- properties: {
16
- skill_name: {type: "string"}
17
- },
18
- required: %w[skill_name]
19
- }
20
- end
21
-
22
- # @param main_session [Session] the session to deactivate the skill on
23
- def initialize(main_session:, **)
24
- @main_session = main_session
25
- end
26
-
27
- # @param input [Hash<String, Object>] with "skill_name" key
28
- # @return [String] confirmation message
29
- # @return [Hash] with :error key on validation failure
30
- def execute(input)
31
- skill_name = input["skill_name"].to_s.strip
32
- return {error: "Skill name cannot be blank"} if skill_name.empty?
33
-
34
- @main_session.deactivate_skill(skill_name)
35
- "Deactivated skill: #{skill_name}"
36
- end
37
- end
38
- end
39
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AnalyticalBrain
4
- module Tools
5
- # Deactivates the current workflow on the main session.
6
- # The workflow's content is removed from the main agent's system prompt.
7
- class DeactivateWorkflow < ::Tools::Base
8
- def self.tool_name = "deactivate_workflow"
9
-
10
- def self.description = "Deactivate the current workflow when it is complete or no longer relevant."
11
-
12
- def self.input_schema
13
- {
14
- type: "object",
15
- properties: {},
16
- required: []
17
- }
18
- end
19
-
20
- # @param main_session [Session] the session to deactivate the workflow on
21
- def initialize(main_session:, **)
22
- @main_session = main_session
23
- end
24
-
25
- # @param input [Hash<String, Object>] (no parameters needed)
26
- # @return [String] confirmation message
27
- def execute(_input)
28
- previous = @main_session.active_workflow
29
- @main_session.deactivate_workflow
30
- previous ? "Deactivated workflow: #{previous}" : "No workflow was active"
31
- end
32
- end
33
- end
34
- end