anima-core 1.4.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -20
  3. data/README.md +61 -95
  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 +13 -2
  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 +21 -10
  30. data/app/models/message.rb +47 -36
  31. data/app/models/pending_message.rb +276 -29
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +468 -432
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +17 -4
  36. data/config/application.rb +1 -0
  37. data/config/initializers/event_subscribers.rb +71 -4
  38. data/config/initializers/inflections.rb +3 -1
  39. data/db/cable_structure.sql +3 -3
  40. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  41. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  42. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  43. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  44. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  45. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  46. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  47. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  48. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  49. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  50. data/db/queue_structure.sql +13 -13
  51. data/db/structure.sql +44 -31
  52. data/lib/agents/registry.rb +1 -1
  53. data/lib/anima/settings.rb +7 -33
  54. data/lib/anima/version.rb +1 -1
  55. data/lib/events/authentication_required.rb +24 -0
  56. data/lib/events/bounce_back.rb +4 -4
  57. data/lib/events/eviction_completed.rb +28 -0
  58. data/lib/events/goal_created.rb +28 -0
  59. data/lib/events/goal_updated.rb +32 -0
  60. data/lib/events/llm_responded.rb +35 -0
  61. data/lib/events/message_created.rb +27 -0
  62. data/lib/events/message_updated.rb +25 -0
  63. data/lib/events/session_state_changed.rb +30 -0
  64. data/lib/events/skill_activated.rb +28 -0
  65. data/lib/events/start_melete.rb +36 -0
  66. data/lib/events/start_mneme.rb +33 -0
  67. data/lib/events/start_processing.rb +32 -0
  68. data/lib/events/subagent_evicted.rb +31 -0
  69. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  70. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  71. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  72. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  73. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  74. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  75. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  76. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  77. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  78. data/lib/events/subscribers/persister.rb +6 -8
  79. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  80. data/lib/events/subscribers/subagent_message_router.rb +26 -29
  81. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  82. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  83. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  84. data/lib/events/tool_executed.rb +34 -0
  85. data/lib/events/workflow_activated.rb +27 -0
  86. data/lib/llm/client.rb +41 -201
  87. data/lib/mcp/client_manager.rb +41 -46
  88. data/lib/mcp/stdio_transport.rb +9 -5
  89. data/lib/{analytical_brain → melete}/runner.rb +63 -68
  90. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
  91. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
  92. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  93. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
  94. data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
  95. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
  96. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  97. data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
  98. data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
  99. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  100. data/lib/mneme/base_runner.rb +121 -0
  101. data/lib/mneme/l2_runner.rb +14 -20
  102. data/lib/mneme/recall_runner.rb +132 -0
  103. data/lib/mneme/runner.rb +118 -171
  104. data/lib/mneme/search.rb +104 -62
  105. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  106. data/lib/mneme/tools/save_snapshot.rb +2 -10
  107. data/lib/mneme/tools/surface_memory.rb +89 -0
  108. data/lib/mneme.rb +11 -5
  109. data/lib/shell_session.rb +287 -612
  110. data/lib/skills/definition.rb +2 -2
  111. data/lib/skills/registry.rb +1 -1
  112. data/lib/tools/base.rb +16 -0
  113. data/lib/tools/bash.rb +25 -57
  114. data/lib/tools/edit.rb +2 -0
  115. data/lib/tools/read.rb +2 -0
  116. data/lib/tools/registry.rb +79 -3
  117. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  118. data/lib/tools/spawn_specialist.rb +16 -10
  119. data/lib/tools/spawn_subagent.rb +20 -14
  120. data/lib/tools/subagent_prompts.rb +4 -4
  121. data/lib/tools/think.rb +1 -1
  122. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  123. data/lib/tools/write.rb +2 -0
  124. data/lib/tui/app.rb +5 -4
  125. data/lib/tui/braille_spinner.rb +7 -7
  126. data/lib/tui/decorators/base_decorator.rb +24 -3
  127. data/lib/tui/message_store.rb +93 -44
  128. data/lib/tui/screens/chat.rb +94 -20
  129. data/lib/tui/settings.rb +9 -2
  130. data/lib/workflows/definition.rb +3 -3
  131. data/lib/workflows/registry.rb +1 -1
  132. data/skills/github.md +38 -0
  133. data/templates/config.toml +4 -23
  134. data/workflows/review_pr.md +18 -14
  135. metadata +86 -28
  136. data/app/jobs/agent_request_job.rb +0 -199
  137. data/app/jobs/analytical_brain_job.rb +0 -33
  138. data/app/jobs/count_message_tokens_job.rb +0 -39
  139. data/app/jobs/passive_recall_job.rb +0 -24
  140. data/app/models/concerns/message/broadcasting.rb +0 -86
  141. data/lib/agent_loop.rb +0 -215
  142. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
  143. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
  144. data/lib/events/agent_message.rb +0 -25
  145. data/lib/events/subscribers/message_collector.rb +0 -64
  146. data/lib/events/tool_call.rb +0 -31
  147. data/lib/events/tool_response.rb +0 -33
  148. data/lib/mneme/compressed_viewport.rb +0 -204
  149. data/lib/mneme/passive_recall.rb +0 -138
data/lib/llm/client.rb CHANGED
@@ -1,15 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- # Convenience layer over {Providers::Anthropic} for sending messages
5
- # and handling tool execution loops.
4
+ # Convenience layer over {Providers::Anthropic} for phantom sessions
5
+ # (Mneme, Melete, Mneme::L2Runner) that need a multi-round tool-use
6
+ # loop driven from plain Ruby objects rather than the main drain
7
+ # pipeline.
8
+ #
9
+ # The main agent loop does NOT use this class — {DrainJob} talks to
10
+ # the provider directly and emits {Events::LLMResponded} for
11
+ # {Events::Subscribers::LLMResponseHandler} to process. The tool loop
12
+ # here is deliberately minimal: no events, no AASM transitions, no
13
+ # interrupt handling — phantom sessions don't interact with those
14
+ # machineries.
6
15
  #
7
16
  # @example
8
17
  # registry = Tools::Registry.new
9
- # registry.register(Tools::WebGet)
10
- # client.chat_with_tools(messages, registry: registry, session_id: session.id)
18
+ # registry.register(Tools::SaveSnapshot)
19
+ # client.chat_with_tools(messages, registry: registry)
11
20
  class Client
12
- # Synthetic tool_result when a tool is skipped because the human pressed Escape.
21
+ # Synthetic tool_result text shown when a tool run is aborted by the
22
+ # user's Escape press. Mirrored into the interrupt subsystem so both
23
+ # the bash tool and any future interrupt handler share the phrasing.
13
24
  INTERRUPT_MESSAGE = "Your human wants your attention"
14
25
 
15
26
  # @return [Providers::Anthropic] the underlying API provider
@@ -33,31 +44,20 @@ module LLM
33
44
  @logger = logger
34
45
  end
35
46
 
36
- # Send messages with tool support. Runs the full tool execution loop:
37
- # call LLM, execute any requested tools, feed results back, repeat
38
- # until the LLM produces a final text response.
39
- #
40
- # Emits {Events::ToolCall} and {Events::ToolResponse} events for each
41
- # tool interaction so they're persisted and visible in the event stream.
47
+ # Runs a minimal multi-round tool-use cycle: call the LLM, execute
48
+ # any requested tools, feed results back, repeat until the LLM
49
+ # produces a final text response.
42
50
  #
43
- # When the user interrupts via Escape, remaining tools receive synthetic
44
- # "Your human wants your attention" results and the loop exits without another LLM call.
51
+ # Intended for phantom sessions (Mneme, Melete). No events are
52
+ # emitted and no persistence happens the caller is responsible
53
+ # for capturing whatever state the tool runs produce.
45
54
  #
46
55
  # @param messages [Array<Hash>] conversation messages in Anthropic format
47
56
  # @param registry [Tools::Registry] registered tools to make available
48
- # @param session_id [Integer, String] session ID for emitted events
49
- # @param first_response [Hash, nil] pre-fetched first API response from
50
- # {AgentLoop#deliver!}. Skips the first API call when provided so
51
- # the Bounce Back transaction doesn't duplicate work.
52
- # @param between_rounds [#call, nil] callback invoked after each tool
53
- # round completes, before the next LLM request. Must return an
54
- # +Array<String>+ of message contents to inject (e.g. promoted
55
- # pending messages). Injected as +text+ blocks alongside
56
- # +tool_result+ blocks so the LLM sees them in the next round.
57
57
  # @param options [Hash] additional API parameters (e.g. +system:+)
58
- # @return [Hash, nil] +:text+ (String) and +:api_metrics+ (Hash), or nil when interrupted
58
+ # @return [Hash] +:text+ (String) and +:api_metrics+ (Hash)
59
59
  # @raise [Providers::Anthropic::Error] on API errors
60
- def chat_with_tools(messages, registry:, session_id:, first_response: nil, between_rounds: nil, **options)
60
+ def chat_with_tools(messages, registry:, **options)
61
61
  messages = messages.dup
62
62
  rounds = 0
63
63
  last_api_metrics = nil
@@ -69,49 +69,26 @@ module LLM
69
69
  return {text: "[Tool loop exceeded #{max_rounds} rounds — halting]", api_metrics: last_api_metrics}
70
70
  end
71
71
 
72
- response = if first_response && rounds == 1
73
- first_response
74
- else
75
- broadcast_session_state(session_id, "llm_generating")
76
- provider.create_message(
77
- model: model,
78
- messages: messages,
79
- max_tokens: max_tokens,
80
- tools: registry.schemas,
81
- include_metrics: true,
82
- **options
83
- )
84
- end
72
+ response = provider.create_message(
73
+ model: model,
74
+ messages: messages,
75
+ max_tokens: max_tokens,
76
+ tools: registry.schemas,
77
+ include_metrics: true,
78
+ **options
79
+ )
85
80
 
86
- # Capture api_metrics from ApiResponse wrapper (nil for pre-fetched first_response)
87
81
  last_api_metrics = response.api_metrics if response.respond_to?(:api_metrics)
88
82
 
89
83
  log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
90
84
 
91
85
  if response["stop_reason"] == "tool_use"
92
- tool_results = execute_tools(response, registry, session_id)
93
- promoted = promote_between_rounds(between_rounds)
94
-
95
- # Dual injection: user messages go as text blocks within the current
96
- # tool_results turn (same speaker); sub-agent messages append as
97
- # separate assistant→user turn pairs (distinct tool invocations).
98
- promoted[:texts].each { |text| tool_results << {type: "text", text: text} }
99
-
86
+ tool_results = execute_tools(response, registry)
100
87
  messages += [
101
88
  {role: "assistant", content: response["content"]},
102
89
  {role: "user", content: tool_results}
103
90
  ]
104
-
105
- messages.concat(promoted[:pairs])
106
-
107
- return nil if handle_interrupt!(session_id)
108
91
  else
109
- # Discard the text response if the user pressed Escape while
110
- # the API was generating it. Without this check the interrupt
111
- # flag set during the blocking API call would be silently
112
- # cleared by the ensure block in AgentRequestJob.
113
- return nil if handle_interrupt!(session_id)
114
-
115
92
  return {text: extract_text(response), api_metrics: last_api_metrics}
116
93
  end
117
94
  end
@@ -119,26 +96,12 @@ module LLM
119
96
 
120
97
  private
121
98
 
122
- # Invokes the between_rounds callback and returns promoted messages
123
- # split by injection strategy.
124
- #
125
- # @param between_rounds [#call, nil] callback returning
126
- # +{texts: Array<String>, pairs: Array<Hash>}+
127
- # @return [Hash{Symbol => Array}] +:texts+ for user messages (text blocks
128
- # in current tool_results), +:pairs+ for sub-agent messages (separate
129
- # conversation turns)
130
- def promote_between_rounds(between_rounds)
131
- return {texts: [], pairs: []} unless between_rounds
132
- between_rounds.call
133
- end
134
-
135
99
  def build_provider(provider)
136
100
  provider || Providers::Anthropic.new
137
101
  end
138
102
 
139
103
  def extract_text(response)
140
104
  content = response["content"] || []
141
-
142
105
  content
143
106
  .select { |block| block["type"] == "text" }
144
107
  .map { |block| block["text"] }
@@ -150,157 +113,36 @@ module LLM
150
113
  content.select { |block| block["type"] == "tool_use" }
151
114
  end
152
115
 
153
- # Executes all tool_use blocks from a response, emitting events for each.
154
- # Checks for user interrupt between toolsremaining tools receive
155
- # synthetic results to satisfy the Anthropic API's tool_use/tool_result
156
- # pairing requirement (a missing result permanently breaks the conversation).
157
- #
158
- # @param response [Hash] Anthropic API response with tool_use content blocks
159
- # @param registry [Tools::Registry] tool registry for dispatch
160
- # @param session_id [Integer, String] session ID for events
161
- # @return [Array<Hash>] tool_result content blocks for the next API call
162
- def execute_tools(response, registry, session_id)
163
- tool_uses = extract_tool_uses(response)
164
- results = []
165
- interrupted = false
166
-
167
- tool_uses.each_with_index do |tool_use, index|
168
- # Check-only here; clearing happens in handle_interrupt! after the loop
169
- interrupted ||= interrupt_requested?(session_id)
170
- if interrupted
171
- remaining = tool_uses[index..]
172
- results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
173
- break
174
- end
175
- results << execute_single_tool(tool_use, registry, session_id)
176
- end
177
-
178
- results
179
- end
180
-
181
- # Creates synthetic "Your human wants your attention" results for all tools in the list.
182
- #
183
- # @param tool_uses [Array<Hash>] remaining tool_use content blocks
184
- # @param session_id [Integer, String] session ID for events
185
- # @return [Array<Hash>] tool_result content blocks
186
- def interrupt_remaining_tools(tool_uses, session_id)
187
- tool_uses.map { |tool_use| interrupt_tool(tool_use, session_id) }
116
+ # Executes every +tool_use+ block from the response and returns
117
+ # matching +tool_result+ blocks. Always emits a result a missing
118
+ # result permanently corrupts the Anthropic conversation history.
119
+ def execute_tools(response, registry)
120
+ extract_tool_uses(response).map { |tool_use| execute_single_tool(tool_use, registry) }
188
121
  end
189
122
 
190
- # Executes a single tool and always returns a tool_result — even if the
191
- # tool raises. Per the Anthropic tool-use protocol, every tool_use must
192
- # have a matching tool_result; a missing result permanently corrupts the
193
- # conversation history and breaks the session.
194
- #
195
- # Falls back to SecureRandom.uuid when Anthropic omits the tool_use id,
196
- # ensuring the ToolCall/ToolResponse pair always shares a valid identifier.
197
- def execute_single_tool(tool_use, registry, session_id)
123
+ def execute_single_tool(tool_use, registry)
198
124
  name = tool_use["name"]
199
125
  id = tool_use["id"] || SecureRandom.uuid
200
126
  input = tool_use["input"] || {}
201
- timeout = input["timeout"] || Anima::Settings.tool_timeout
202
127
 
203
128
  log(:debug, "tool_call: #{name}(#{input.to_json})")
204
129
 
205
- broadcast_session_state(session_id, "tool_executing", tool: name)
206
-
207
- Events::Bus.emit(Events::ToolCall.new(
208
- content: "Calling #{name}", tool_name: name,
209
- tool_input: input, tool_use_id: id, timeout: timeout,
210
- session_id: session_id
211
- ))
212
-
213
130
  result = registry.execute(name, input)
214
131
  result = ToolDecorator.call(name, result)
215
132
  result_content = format_tool_result(result)
216
133
  result_content = truncate_tool_result(result_content, registry, name)
217
- log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
218
134
 
219
- Events::Bus.emit(Events::ToolResponse.new(
220
- content: result_content, tool_name: name, tool_use_id: id,
221
- success: !result.is_a?(Hash) || !result.key?(:error),
222
- session_id: session_id
223
- ))
135
+ log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
224
136
 
225
137
  {type: "tool_result", tool_use_id: id, content: result_content}
226
138
  rescue => error
227
139
  error_detail = "#{error.class}: #{error.message}"
228
140
  Rails.logger.error("Tool #{name} raised #{error_detail}")
229
- error_content = format_tool_result(error: error_detail)
230
-
231
- # Emission can fail (e.g. encoding errors in ActionCable/SQLite),
232
- # but losing the tool_result would permanently corrupt the session.
233
- begin
234
- Events::Bus.emit(Events::ToolResponse.new(
235
- content: error_content, tool_name: name, tool_use_id: id,
236
- success: false, session_id: session_id
237
- ))
238
- rescue => emit_error
239
- Rails.logger.error("ToolResponse emission failed: #{emit_error.class}: #{emit_error.message}")
240
- end
241
-
242
- {type: "tool_result", tool_use_id: id, content: error_content}
243
- end
244
-
245
- # Creates a synthetic "Your human wants your attention" result for a tool that was not
246
- # executed due to user interrupt. Emits both ToolCall and ToolResponse
247
- # events so the TUI shows the interrupted tool in the event stream.
248
- #
249
- # @param tool_use [Hash] Anthropic tool_use content block
250
- # @param session_id [Integer, String] session ID for events
251
- # @return [Hash] tool_result content block
252
- def interrupt_tool(tool_use, session_id)
253
- name = tool_use["name"]
254
- id = tool_use["id"] || SecureRandom.uuid
255
- input = tool_use["input"] || {}
256
-
257
- Events::Bus.emit(Events::ToolCall.new(
258
- content: "Skipped #{name} — your human wants your attention", tool_name: name,
259
- tool_input: input, tool_use_id: id, session_id: session_id
260
- ))
261
-
262
- Events::Bus.emit(Events::ToolResponse.new(
263
- content: INTERRUPT_MESSAGE, tool_name: name, tool_use_id: id,
264
- success: false, session_id: session_id
265
- ))
266
-
267
- {type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
268
- end
269
-
270
- # Checks whether the session has a pending interrupt flag.
271
- #
272
- # @param session_id [Integer, String] session to check
273
- # @return [Boolean] true when interrupt is pending
274
- def interrupt_requested?(session_id)
275
- Session.where(id: session_id, interrupt_requested: true).exists?
276
- end
277
-
278
- # Atomically checks for a pending interrupt and clears it in one query.
279
- # Used at loop boundaries (after tools, before LLM text return) to
280
- # short-circuit the agent loop when the user presses Escape.
281
- #
282
- # @param session_id [Integer, String] session to check
283
- # @return [Boolean] true when interrupt was detected and cleared
284
- def handle_interrupt!(session_id)
285
- Session.where(id: session_id, interrupt_requested: true)
286
- .update_all(interrupt_requested: false) > 0
287
- end
288
-
289
- # Broadcasts a session state transition to all subscribed clients.
290
- # Delegates to {Session#broadcast_session_state} which handles both
291
- # the session's own stream and the parent's stream for HUD updates.
292
- #
293
- # @param session_id [Integer, String] session to broadcast for
294
- # @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
295
- # @param tool [String, nil] tool name when state is "tool_executing"
296
- # @return [void]
297
- def broadcast_session_state(session_id, state, tool: nil)
298
- Session.find_by(id: session_id)&.broadcast_session_state(state, tool: tool)
141
+ {type: "tool_result", tool_use_id: id, content: format_tool_result(error: error_detail)}
299
142
  end
300
143
 
301
144
  def log(level, message)
302
145
  return unless @logger
303
-
304
146
  @logger.public_send(level, message)
305
147
  end
306
148
 
@@ -308,8 +150,6 @@ module LLM
308
150
  result.is_a?(Hash) ? result.to_json : result.to_s
309
151
  end
310
152
 
311
- # Applies head+tail truncation when a tool result exceeds the tool's
312
- # configured character threshold. Skips tools that opt out (e.g. read).
313
153
  def truncate_tool_result(content, registry, tool_name)
314
154
  threshold = registry.truncation_threshold(tool_name)
315
155
  return content unless threshold
@@ -3,81 +3,76 @@
3
3
  require "mcp"
4
4
 
5
5
  module Mcp
6
- # Manages MCP client connections and registers their tools with
7
- # {Tools::Registry}. Each configured server (HTTP or stdio) gets
8
- # a dedicated {MCP::Client} instance. Tool lists are fetched once
9
- # during registration and cached in the registrysubsequent LLM
10
- # turns reuse the same tool set without re-querying servers.
6
+ # Connects to MCP servers and registers their tools with
7
+ # {Tools::Registry}. Each configured server (HTTP or stdio) gets a
8
+ # dedicated {MCP::Client} instance, cached for the worker's
9
+ # lifetime. Connection failures are logged and skippeda
10
+ # misconfigured or unavailable server does not prevent other servers
11
+ # or built-in tools from working.
11
12
  #
12
- # Connection failures are logged and skipped a misconfigured or
13
- # unavailable server does not prevent other servers or built-in
14
- # tools from working.
13
+ # Spawned stdio processes are reaped on worker exit via
14
+ # {Mcp::StdioTransport.cleanup_all}.
15
+ #
16
+ # The cache is built once on the first {#register_tools} call and
17
+ # never invalidated; edits to +mcp.toml+ require a worker restart.
15
18
  #
16
19
  # @example
17
- # manager = Mcp::ClientManager.new
18
- # manager.register_tools(registry)
20
+ # Mcp::ClientManager.shared.register_tools(registry)
19
21
  class ClientManager
22
+ # Lazily-instantiated process-wide manager. Production code should
23
+ # call {.shared}; {.new} is reserved for tests and internal use.
24
+ # @return [Mcp::ClientManager]
25
+ def self.shared
26
+ @shared ||= new
27
+ end
28
+
20
29
  # @param config [Mcp::Config] injectable config for testing
21
30
  def initialize(config: Config.new(logger: Rails.logger))
22
31
  @config = config
23
32
  end
24
33
 
25
- # Connects to all configured MCP servers and registers their tools
26
- # in the given registry. Returns warnings for servers that failed
27
- # to load so the caller can surface them to the user.
34
+ # Connects to every configured MCP server on first call, caches
35
+ # the resulting tool wrappers, and registers them in the given
36
+ # registry.
28
37
  #
29
38
  # @param registry [Tools::Registry] the registry to add tools to
30
- # @return [Array<String>] warning messages for servers that failed
39
+ # @return [Array<String>] warning messages from configuration plus
40
+ # any per-server load failures
31
41
  def register_tools(registry)
32
- warnings = []
33
- register_transport_tools(@config.http_servers, registry, warnings) { |server| build_http_client(server) }
34
- register_transport_tools(@config.stdio_servers, registry, warnings) { |server| build_stdio_client(server) }
35
- @config.warnings + warnings
42
+ load_servers if @wrappers.nil?
43
+ @wrappers.each { |wrapper| registry.register(wrapper) }
44
+ @config.warnings + @warnings
36
45
  end
37
46
 
38
47
  private
39
48
 
40
- # Iterates server configs, builds a client for each via the block,
41
- # and registers the server's tools. Failures are logged and collected.
42
- #
43
- # @param servers [Array<Hash>] server configs from {Mcp::Config}
44
- # @param registry [Tools::Registry] registry to register tools in
45
- # @param warnings [Array<String>] collects failure messages
46
- # @yield [server] block that builds an {MCP::Client} for the server
47
- def register_transport_tools(servers, registry, warnings)
49
+ def load_servers
50
+ @wrappers = []
51
+ @warnings = []
52
+ register_transport_tools(@config.http_servers) { |server| build_http_client(server) }
53
+ register_transport_tools(@config.stdio_servers) { |server| build_stdio_client(server) }
54
+ end
55
+
56
+ def register_transport_tools(servers)
48
57
  servers.each do |server|
49
58
  client = yield(server)
50
- register_server_tools(server[:name], client, registry)
59
+ wrappers = client.tools.map { |mcp_tool|
60
+ Tools::McpTool.new(server_name: server[:name], mcp_client: client, mcp_tool: mcp_tool)
61
+ }
62
+ @wrappers.concat(wrappers)
63
+ Rails.logger.info("MCP: registered #{wrappers.size} tools from #{server[:name]}")
51
64
  rescue => error
52
65
  message = "MCP: failed to load tools from #{server[:name]}: #{error.message}"
53
66
  Rails.logger.warn(message)
54
- warnings << message
67
+ @warnings << message
55
68
  end
56
69
  end
57
70
 
58
- # Fetches tools from an MCP client and registers them with
59
- # namespaced names in the registry.
60
- #
61
- # @param server_name [String] server name for tool namespacing
62
- # @param client [MCP::Client] connected MCP client
63
- # @param registry [Tools::Registry] registry to register tools in
64
- def register_server_tools(server_name, client, registry)
65
- count = client.tools.map { |mcp_tool|
66
- Tools::McpTool.new(server_name: server_name, mcp_client: client, mcp_tool: mcp_tool)
67
- }.each { |wrapper| registry.register(wrapper) }.size
68
-
69
- Rails.logger.info("MCP: registered #{count} tools from #{server_name}")
70
- end
71
-
72
- # @param server [Hash] server config with +:url+ and +:headers+
73
- # @return [MCP::Client]
74
71
  def build_http_client(server)
75
72
  transport = MCP::Client::HTTP.new(url: server[:url], headers: server[:headers])
76
73
  MCP::Client.new(transport: transport)
77
74
  end
78
75
 
79
- # @param server [Hash] server config with +:command+, +:args+, +:env+
80
- # @return [MCP::Client]
81
76
  def build_stdio_client(server)
82
77
  transport = StdioTransport.new(command: server[:command], args: server[:args], env: server[:env])
83
78
  MCP::Client.new(transport: transport)
@@ -115,8 +115,11 @@ module Mcp
115
115
  @wait_thread&.alive? || false
116
116
  end
117
117
 
118
+ # +pgroup: true+ so {#terminate_process} can group-signal the
119
+ # entire descendant tree — npm/npx wrappers leak their +node+
120
+ # children otherwise.
118
121
  def spawn_process
119
- @stdin, @stdout, @wait_thread = Open3.popen2(@env, @command, *@args)
122
+ @stdin, @stdout, @wait_thread = Open3.popen2(@env, @command, *@args, pgroup: true)
120
123
  @stdin.set_encoding("UTF-8")
121
124
  @stdout.set_encoding("UTF-8")
122
125
  self.class.register(self)
@@ -164,14 +167,15 @@ module Mcp
164
167
  @stdout&.close rescue IOError # rubocop:disable Style/RescueModifier
165
168
  end
166
169
 
167
- # Sends SIGTERM and waits up to 2 seconds for the process to exit.
168
- # Falls back to SIGKILL if the process does not terminate in time.
170
+ # Sends SIGTERM to the process group; escalates to SIGKILL on the
171
+ # group after +GRACEFUL_SHUTDOWN_TIMEOUT+ seconds. Negative PID
172
+ # signals the whole group (see {#spawn_process}).
169
173
  def terminate_process
170
174
  return unless @wait_thread
171
175
 
172
176
  pid = @wait_thread.pid
173
177
  begin
174
- Process.kill("TERM", pid)
178
+ Process.kill("TERM", -pid)
175
179
  rescue Errno::ESRCH, Errno::EPERM
176
180
  return
177
181
  end
@@ -181,7 +185,7 @@ module Mcp
181
185
  _, status = Process.wait2(pid, Process::WNOHANG)
182
186
  break if status
183
187
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
184
- Process.kill("KILL", pid) rescue Errno::ESRCH # rubocop:disable Style/RescueModifier
188
+ Process.kill("KILL", -pid) rescue Errno::ESRCH # rubocop:disable Style/RescueModifier
185
189
  Process.wait(pid) rescue Errno::ECHILD # rubocop:disable Style/RescueModifier
186
190
  break
187
191
  end