aia 1.1.1 → 2.0.0.0.pre.beta2

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/.envrc +9 -1
  3. data/.loki +11 -0
  4. data/.reek.yml +160 -0
  5. data/.rubocop.yml +116 -0
  6. data/.rubocop_strict.yml +15 -0
  7. data/.version +1 -1
  8. data/CHANGELOG.md +269 -55
  9. data/IMPLEMENTATION_PLAN.md +506 -0
  10. data/README.md +267 -239
  11. data/Rakefile +5 -5
  12. data/_typos.toml +10 -0
  13. data/aia.gemspec +92 -0
  14. data/architecture_review.md +314 -0
  15. data/bin/aia +16 -0
  16. data/config/aia.yml +13 -0
  17. data/docs/AGENTS.md +40 -0
  18. data/docs/advanced-prompting.md +67 -3
  19. data/docs/cli-reference.md +312 -56
  20. data/docs/configuration.md +130 -19
  21. data/docs/contributing.md +56 -2
  22. data/docs/directives-reference.md +593 -78
  23. data/docs/faq.md +85 -3
  24. data/docs/guides/available-models.md +1 -1
  25. data/docs/guides/basic-usage.md +6 -6
  26. data/docs/guides/chat.md +40 -16
  27. data/docs/guides/crew.md +239 -0
  28. data/docs/guides/executable-prompts.md +1 -1
  29. data/docs/guides/index.md +1 -0
  30. data/docs/guides/models.md +15 -0
  31. data/docs/index.md +29 -2
  32. data/docs/installation.md +44 -17
  33. data/docs/mcp-integration.md +40 -0
  34. data/docs/prompt_management.md +85 -86
  35. data/docs/security.md +47 -0
  36. data/docs/special_projects_guide.md +386 -0
  37. data/docs/tools-and-mcp-examples.md +23 -0
  38. data/docs/workflows-and-pipelines.md +84 -7
  39. data/examples/.gitignore +1 -0
  40. data/examples/00_setup_aia.sh +27 -44
  41. data/examples/11_multi_model.sh +4 -14
  42. data/examples/12_token_usage.sh +3 -12
  43. data/examples/18_tools.sh +10 -2
  44. data/examples/22_chat_mode.sh +0 -10
  45. data/examples/23_verify.sh +139 -0
  46. data/examples/24_decompose.sh +139 -0
  47. data/examples/25_spawn.sh +139 -0
  48. data/examples/26_debate.sh +97 -0
  49. data/examples/27_mention_routing.sh +157 -0
  50. data/examples/28_model_switching.sh +106 -0
  51. data/examples/29_agent_harness.sh +177 -0
  52. data/examples/README.md +65 -0
  53. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  54. data/examples/aia_config.yml +1 -1
  55. data/examples/aia_config_orchestrator.yml +45 -0
  56. data/examples/common.sh +18 -6
  57. data/examples/context/tech_stack.md +2 -2
  58. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  59. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  60. data/examples/rules/01_classify_ruby.rb +16 -0
  61. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  62. data/examples/rules/03_gate_prompt_length.rb +19 -0
  63. data/examples/rules/04_tool_selection.rb +41 -0
  64. data/examples/rules/README.md +30 -0
  65. data/examples/run_all.sh +48 -15
  66. data/examples/tools/word_count_tool.rb +1 -1
  67. data/lib/AGENTS.md +57 -0
  68. data/lib/aia/chat_loop.rb +263 -167
  69. data/lib/aia/config/cli_parser.rb +217 -145
  70. data/lib/aia/config/defaults.yml +62 -33
  71. data/lib/aia/config/mcp_parser.rb +52 -51
  72. data/lib/aia/config/model_spec.rb +34 -2
  73. data/lib/aia/config/validator.rb +171 -216
  74. data/lib/aia/config.rb +111 -145
  75. data/lib/aia/content_extractor.rb +155 -0
  76. data/lib/aia/cost_calculator.rb +39 -0
  77. data/lib/aia/crew.rb +164 -0
  78. data/lib/aia/debate_handler.rb +174 -0
  79. data/lib/aia/delegate_handler.rb +116 -0
  80. data/lib/aia/directive.rb +43 -26
  81. data/lib/aia/directive_processor.rb +16 -7
  82. data/lib/aia/directives/configuration_directives.rb +214 -60
  83. data/lib/aia/directives/context_directives.rb +67 -52
  84. data/lib/aia/directives/execution_directives.rb +141 -4
  85. data/lib/aia/directives/model_directives.rb +163 -141
  86. data/lib/aia/directives/trakflow_directives.rb +62 -0
  87. data/lib/aia/directives/utility_directives.rb +227 -30
  88. data/lib/aia/directives/web_and_file_directives.rb +126 -77
  89. data/lib/aia/errors.rb +15 -0
  90. data/lib/aia/fact_asserter.rb +27 -0
  91. data/lib/aia/fzf.rb +9 -31
  92. data/lib/aia/handler_context.rb +17 -0
  93. data/lib/aia/handler_protocol.rb +19 -0
  94. data/lib/aia/history_transfer.rb +55 -0
  95. data/lib/aia/input_collector.rb +3 -3
  96. data/lib/aia/layered_orchestrator.rb +471 -0
  97. data/lib/aia/logger.rb +45 -25
  98. data/lib/aia/mcp_config_normalizer.rb +35 -0
  99. data/lib/aia/mcp_connection_manager.rb +315 -0
  100. data/lib/aia/mcp_discovery.rb +44 -0
  101. data/lib/aia/mcp_grouper.rb +33 -0
  102. data/lib/aia/mcp_server_config.rb +30 -0
  103. data/lib/aia/mcp_utility.rb +60 -0
  104. data/lib/aia/mention_router.rb +217 -0
  105. data/lib/aia/model_alias_registry.rb +97 -0
  106. data/lib/aia/model_switch_handler.rb +100 -0
  107. data/lib/aia/network_builder.rb +160 -0
  108. data/lib/aia/network_memory_manager.rb +55 -0
  109. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  110. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  111. data/lib/aia/pipeline_orchestrator.rb +272 -0
  112. data/lib/aia/plugin_loader.rb +170 -0
  113. data/lib/aia/plugin_monitor.rb +211 -0
  114. data/lib/aia/prompt_decomposer.rb +159 -0
  115. data/lib/aia/prompt_handler.rb +56 -82
  116. data/lib/aia/robot_builder.rb +51 -0
  117. data/lib/aia/robot_factory.rb +338 -0
  118. data/lib/aia/robot_namer.rb +110 -0
  119. data/lib/aia/session.rb +87 -17
  120. data/lib/aia/session_tracker.rb +207 -0
  121. data/lib/aia/similarity_scorer.rb +41 -0
  122. data/lib/aia/skill_utils.rb +105 -1
  123. data/lib/aia/spawn_handler.rb +129 -0
  124. data/lib/aia/spawn_spec_parser.rb +65 -0
  125. data/lib/aia/special_mode_handler.rb +322 -0
  126. data/lib/aia/speech.rb +67 -0
  127. data/lib/aia/startup_coordinator.rb +151 -0
  128. data/lib/aia/streaming_runner.rb +172 -0
  129. data/lib/aia/system_prompt_assembler.rb +92 -0
  130. data/lib/aia/task_coordinator.rb +207 -0
  131. data/lib/aia/task_decomposer.rb +57 -0
  132. data/lib/aia/task_executor.rb +51 -0
  133. data/lib/aia/tfidf_math.rb +27 -0
  134. data/lib/aia/timing.rb +15 -0
  135. data/lib/aia/tool_filter/tfidf.rb +116 -0
  136. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  137. data/lib/aia/tool_filter.rb +83 -0
  138. data/lib/aia/tool_filter_registry.rb +30 -0
  139. data/lib/aia/tool_filter_strategy.rb +146 -0
  140. data/lib/aia/tool_introspection.rb +17 -0
  141. data/lib/aia/tool_loader.rb +216 -0
  142. data/lib/aia/tool_utility.rb +30 -0
  143. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  144. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  145. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  146. data/lib/aia/tools/task_board_tool.rb +115 -0
  147. data/lib/aia/trakflow_bridge.rb +175 -0
  148. data/lib/aia/turn_state.rb +95 -0
  149. data/lib/aia/ui_presenter.rb +182 -206
  150. data/lib/aia/utility.rb +136 -87
  151. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
  152. data/lib/aia/verification_network.rb +57 -0
  153. data/lib/aia.rb +124 -63
  154. data/mkdocs.yml +1 -0
  155. metadata +187 -58
  156. data/justfile +0 -215
  157. data/lib/aia/adapter/chat_execution.rb +0 -242
  158. data/lib/aia/adapter/error_handler.rb +0 -68
  159. data/lib/aia/adapter/gem_activator.rb +0 -57
  160. data/lib/aia/adapter/mcp_connector.rb +0 -274
  161. data/lib/aia/adapter/modality_handlers.rb +0 -167
  162. data/lib/aia/adapter/model_registry.rb +0 -81
  163. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  164. data/lib/aia/adapter/provider_configurator.rb +0 -59
  165. data/lib/aia/adapter/tool_filter.rb +0 -85
  166. data/lib/aia/adapter/tool_loader.rb +0 -90
  167. data/lib/aia/chat_processor_service.rb +0 -178
  168. data/lib/aia/prompt_pipeline.rb +0 -183
  169. data/lib/aia/ruby_llm_adapter.rb +0 -95
  170. data/lib/extensions/openstruct_merge.rb +0 -48
  171. data/lib/extensions/ruby_llm/.irbrc +0 -56
  172. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  173. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  174. data/lib/refinements/string.rb +0 -16
  175. data/main.just +0 -76
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/mcp_config_normalizer.rb
4
+ #
5
+ # Normalizes a single MCP server config from AIA flat format to the nested
6
+ # transport format expected by robot_lab. Server selection/filtering is
7
+ # the responsibility of MCPDiscovery — this class only transforms shape.
8
+
9
+ module AIA
10
+ class MCPConfigNormalizer
11
+ class << self
12
+ # Normalize a single MCP server config to robot_lab's nested transport format.
13
+ #
14
+ # @param server [Hash]
15
+ # @return [Hash]
16
+ def normalize(server)
17
+ server = server.is_a?(Hash) ? server.transform_keys(&:to_sym) : server.to_h.transform_keys(&:to_sym)
18
+
19
+ # Already in robot_lab format — pass through
20
+ return server if server[:transport]
21
+
22
+ # Legacy flat format: wrap command/args/env into transport
23
+ name = server[:name]
24
+ transport = { type: server[:type] || 'stdio' }
25
+ transport[:command] = server[:command] if server[:command]
26
+ transport[:args] = Array(server[:args]) if server[:args]
27
+ transport[:env] = server[:env] if server[:env]
28
+
29
+ result = { name: name, transport: transport }
30
+ result[:timeout] = server[:timeout] if server[:timeout]
31
+ result
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/mcp_connection_manager.rb
4
+ #
5
+ # Single owner for MCP connection state. Handles connecting to configured
6
+ # MCP servers concurrently, tracking successes/failures, and injecting
7
+ # results into robots.
8
+
9
+ require 'timeout'
10
+ require 'tty-spinner'
11
+
12
+ module AIA
13
+ # :reek:TooManyMethods -- one small helper per connection-lifecycle concern (connect, wrap tools, record outcomes)
14
+ class MCPConnectionManager
15
+ # Maximum seconds to wait for a single MCP server to connect.
16
+ DEFAULT_TIMEOUT = 30
17
+
18
+ attr_reader :connected_clients, :connected_tools, :failed_servers
19
+
20
+ def initialize
21
+ @connected_clients = {}
22
+ @connected_tools = []
23
+ @failed_servers = []
24
+ @server_tool_counts = {}
25
+ @connected = false
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ # Connect all configured MCP servers concurrently.
30
+ # Each server gets its own spinner line and thread.
31
+ #
32
+ # @param servers [Array<Hash>] MCP server configurations
33
+ # @return [self]
34
+ # :reek:TooManyStatements -- connection ceremony: spinner setup, thread launch, global timeout guard, summary logging
35
+ def connect_all(servers)
36
+ return self unless servers.is_a?(Array) && servers.any?
37
+
38
+ reset_connection_state
39
+ logger = AIA::LoggerManager.mcp_logger
40
+ server_names = servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
41
+ logger.info("MCP initialization: connecting #{servers.size} server(s): #{server_names.join(', ')}")
42
+
43
+ multi = TTY::Spinner::Multi.new("[:spinner] Connecting MCP servers", format: :dots, output: $stderr)
44
+ threads = launch_server_threads(servers, multi, logger)
45
+
46
+ # The global cap must never be smaller than the slowest single server's
47
+ # timeout, or concurrent connections get killed before they can finish.
48
+ global_limit = [global_connection_timeout, *servers.map { |s| server_timeout(s) }].max
49
+ Timeout.timeout(global_limit) { threads.each(&:join) }
50
+
51
+ @connected = true
52
+ logger.info("MCP initialization complete: #{@connected_clients.size} connected, #{@failed_servers.size} failed")
53
+ self
54
+ rescue Timeout::Error
55
+ handle_global_timeout(servers, global_limit)
56
+ end
57
+
58
+ # Inject connected MCP clients and tools into robot(s).
59
+ #
60
+ # @param robot [RobotLab::Robot, RobotLab::Network] the robot or network
61
+ # @return [self]
62
+ def inject_into(robot)
63
+ clients, tools = @mutex.synchronize { [@connected_clients.dup, @connected_tools.dup] }
64
+
65
+ targets = robot.crew
66
+
67
+ targets.each do |target|
68
+ target.inject_mcp!(clients: clients, tools: tools)
69
+ end
70
+
71
+ self
72
+ end
73
+
74
+ # Update AIA config with connection results.
75
+ #
76
+ # @return [self]
77
+ def update_config
78
+ connected_keys, tool_counts, failed = @mutex.synchronize do
79
+ [@connected_clients.keys.dup, @server_tool_counts.dup, @failed_servers.dup]
80
+ end
81
+ cfg = AIA.config
82
+ cfg.connected_mcp_servers = connected_keys
83
+ cfg.mcp_server_tool_counts = tool_counts
84
+ cfg.failed_mcp_servers = failed
85
+ self
86
+ end
87
+
88
+ # Whether connect_all has been called.
89
+ def connected?
90
+ @connected
91
+ end
92
+
93
+ # Whether any tools were collected (from config servers or Ruby require).
94
+ def any_tools?
95
+ @mutex.synchronize { @connected_tools.any? }
96
+ end
97
+
98
+ # Absorb MCP clients registered in RubyLLM::MCP (e.g., via --require loading
99
+ # shared_tools/mcp/* files) that are not already tracked by this manager.
100
+ # Starts any unstarted clients, extracts their tools, and merges them into
101
+ # the connected state so inject_into will include them in the robot.
102
+ #
103
+ # @return [self]
104
+ # :reek:TooManyStatements -- per-client absorb loop: start if needed, extract tools, merge into connected state with error capture
105
+ def absorb_ruby_llm_mcp_clients
106
+ return self unless defined?(RubyLLM::MCP)
107
+
108
+ logger = AIA::LoggerManager.mcp_logger
109
+
110
+ RubyLLM::MCP.clients.each do |name, client|
111
+ next if @connected_clients.key?(name)
112
+
113
+ begin
114
+ client.start unless client.alive?
115
+
116
+ if client.alive?
117
+ tools = client.tools rescue []
118
+ @mutex.synchronize do
119
+ @connected_clients[name] = client
120
+ @server_tool_counts[name] = tools.size
121
+ @connected_tools.concat(tools)
122
+ end
123
+ logger.info("MCP: absorbed RubyLLM::MCP client '#{name}' (#{tools.size} tools)")
124
+ else
125
+ logger.warn("MCP: RubyLLM::MCP client '#{name}' not alive, skipping")
126
+ end
127
+ rescue StandardError => e
128
+ logger.warn("MCP: error absorbing RubyLLM::MCP client '#{name}': #{e.message}")
129
+ end
130
+ end
131
+
132
+ @connected = true unless RubyLLM::MCP.clients.empty?
133
+ self
134
+ end
135
+
136
+ # Find an MCP client by server name.
137
+ #
138
+ # @param name [String] server name
139
+ # @return [RobotLab::MCP::Client, nil]
140
+ def client(name)
141
+ @connected_clients[name]
142
+ end
143
+
144
+ # Connected server names.
145
+ #
146
+ # @return [Array<String>]
147
+ def connected_server_names
148
+ @mutex.synchronize { @connected_clients.keys.dup }
149
+ end
150
+
151
+ # Failed server names.
152
+ #
153
+ # @return [Array<String>]
154
+ def failed_server_names
155
+ @mutex.synchronize { @failed_servers.map { |f| f[:name] } }
156
+ end
157
+
158
+ # Close all connected MCP clients and reset connection state.
159
+ # Errors from individual client closes are silently rescued —
160
+ # best-effort cleanup on process exit.
161
+ def close_all
162
+ clients = @mutex.synchronize { @connected_clients.dup }
163
+ clients.each_value do |client|
164
+ client.close
165
+ rescue StandardError
166
+ # best-effort — client may already be closed
167
+ end
168
+ @mutex.synchronize do
169
+ @connected_clients = {}
170
+ @connected_tools = []
171
+ @server_tool_counts = {}
172
+ @connected = false
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def reset_connection_state
179
+ @connected_clients = {}
180
+ @connected_tools = []
181
+ @failed_servers = []
182
+ end
183
+
184
+ def launch_server_threads(servers, multi, logger)
185
+ servers.map do |server_config|
186
+ name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
187
+ spinner = multi.register("[:spinner] #{name}")
188
+ Thread.new(server_config, name, spinner) { |cfg, srv_name, sp| connect_one(cfg, srv_name, sp, logger) }
189
+ end
190
+ end
191
+
192
+ def handle_global_timeout(servers, global_limit)
193
+ logger = AIA::LoggerManager.mcp_logger
194
+ logger.warn("MCP: Global connection timeout (#{global_limit}s) reached")
195
+
196
+ connected_names, failed_names = @mutex.synchronize do
197
+ [@connected_clients.keys.to_set, @failed_servers.to_set { |f| f[:name] }]
198
+ end
199
+
200
+ stragglers = servers.filter_map do |server_config|
201
+ srv_name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
202
+ next if connected_names.include?(srv_name) || failed_names.include?(srv_name)
203
+
204
+ { name: srv_name, error: "global connection timeout (#{global_limit}s)" }
205
+ end
206
+ @mutex.synchronize { @failed_servers.concat(stragglers) }
207
+ end
208
+
209
+ # Read global MCP connection timeout from config.
210
+ # Falls back to 120s if config is unavailable.
211
+ def global_connection_timeout
212
+ AIA.config&.concurrency&.mcp_timeout.to_i.then { |v| v.positive? ? v : 120 }
213
+ rescue StandardError
214
+ 120
215
+ end
216
+
217
+ # Connect a single MCP server, updating the spinner on completion.
218
+ # :reek:TooManyStatements -- one connect attempt with per-outcome reporting (success, refused, timeout, error)
219
+ def connect_one(server_config, name, spinner, logger)
220
+ timeout = server_timeout(server_config)
221
+ spinner.auto_spin
222
+
223
+ logger.info("MCP: connecting to '#{name}'...")
224
+ Timeout.timeout(timeout) do
225
+ client = RobotLab::MCP::Client.new(server_config)
226
+ client.connect
227
+
228
+ if client.connected?
229
+ record_connected(name, client, build_mcp_tools(client, name), spinner, logger)
230
+ else
231
+ record_failed_server(name, "connection failed")
232
+ logger.warn("MCP: '#{name}' failed to connect")
233
+ spinner.error("(connection failed)")
234
+ end
235
+ end
236
+ rescue Timeout::Error
237
+ record_failed_server(name, "timed out after #{timeout}s")
238
+ logger.warn("MCP: '#{name}' timed out after #{timeout}s")
239
+ spinner.error("(timed out)")
240
+ rescue StandardError => e
241
+ msg = e.message
242
+ record_failed_server(name, msg)
243
+ logger.warn("MCP: '#{name}' error: #{msg}")
244
+ spinner.error("(#{msg})")
245
+ end
246
+
247
+ # Wrap each remote tool definition in a RobotLab::Tool proxying to the client.
248
+ def build_mcp_tools(client, server_name)
249
+ client.list_tools.map do |tool_def|
250
+ tool_name = tool_def[:name]
251
+ RobotLab::Tool.create(
252
+ name: tool_name,
253
+ description: tool_def[:description],
254
+ parameters: tool_def[:inputSchema],
255
+ mcp: server_name
256
+ ) { |args| client.call_tool(tool_name, args) }
257
+ end
258
+ end
259
+
260
+ def record_connected(name, client, tools, spinner, logger)
261
+ tool_count = tools.size
262
+
263
+ @mutex.synchronize do
264
+ @connected_clients[name] = client
265
+ @server_tool_counts[name] = tool_count
266
+ add_tools_deduped(tools, name, logger)
267
+ end
268
+
269
+ logger.info("MCP: '#{name}' connected (#{tool_count} tools)")
270
+ spinner.success("(#{tool_count} tools)")
271
+ end
272
+
273
+ def record_failed_server(name, error)
274
+ @mutex.synchronize { @failed_servers << { name: name, error: error } }
275
+ end
276
+
277
+ # Add tools to @connected_tools, skipping any whose name is already present.
278
+ # Logs a warning for each duplicate so the user knows which server wins.
279
+ # MUST be called inside @mutex.synchronize.
280
+ #
281
+ # @param new_tools [Array] tools to add
282
+ # @param server_name [String] name of the server providing these tools (for warning)
283
+ # @param logger [Logger] MCP logger
284
+ def add_tools_deduped(new_tools, server_name, logger)
285
+ new_tools.each do |tool|
286
+ tool_name = tool.respond_to?(:name) ? tool.name : tool.to_s
287
+ existing = @connected_tools.find do |t|
288
+ (t.respond_to?(:name) ? t.name : t.to_s) == tool_name
289
+ end
290
+
291
+ if existing
292
+ logger.warn("MCP: Tool '#{tool_name}' already registered from another server. " \
293
+ "Skipping duplicate from '#{server_name}'.")
294
+ else
295
+ @connected_tools << tool
296
+ end
297
+ end
298
+ end
299
+
300
+ # Extract timeout in seconds from server config.
301
+ # Config values >= 1000 are treated as milliseconds.
302
+ def server_timeout(server_config)
303
+ raw = server_config.is_a?(Hash) ? server_config[:timeout] : nil
304
+ return DEFAULT_TIMEOUT if raw.nil?
305
+
306
+ seconds = raw.to_f
307
+ seconds /= 1000.0 if seconds >= 1000 # values >= 1000 are milliseconds
308
+ # Honor the configured per-server timeout. DEFAULT_TIMEOUT is only the
309
+ # fallback when no timeout is set (handled above) — it must NOT cap a
310
+ # larger configured value, or slow-starting servers (npx/uvx cold starts)
311
+ # get dropped and their tools never reach the robot or the tool filter.
312
+ seconds.positive? ? seconds : DEFAULT_TIMEOUT
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/mcp_discovery.rb
4
+ #
5
+ # Determines which MCP servers should be activated for a given prompt.
6
+ # Two strategies: explicit (user directive) or all configured (default).
7
+
8
+ module AIA
9
+ class MCPDiscovery
10
+ def initialize; end
11
+
12
+ # Discover relevant MCP servers for the current prompt.
13
+ # Priority: explicit --mcp-use list > all configured servers.
14
+ # --mcp-skip is always applied last.
15
+ #
16
+ # @param config the AIA configuration
17
+ # @return [Array<Hash>] relevant MCP server configs
18
+ def discover(config)
19
+ return [] if config.flags&.no_mcp
20
+
21
+ servers = select_servers(config)
22
+ apply_skip_filter(servers, config)
23
+ end
24
+
25
+ private
26
+
27
+ def select_servers(config)
28
+ return select_by_names(config.mcp_servers || [], Array(config.mcp_use)) if Array(config.mcp_use).any?
29
+
30
+ config.mcp_servers || []
31
+ end
32
+
33
+ def apply_skip_filter(servers, config)
34
+ skip_list = Array(config.mcp_skip)
35
+ return servers if skip_list.empty?
36
+
37
+ servers.reject { |s| skip_list.include?(AIA::Utility.server_name(s)) }
38
+ end
39
+
40
+ def select_by_names(all_servers, names)
41
+ all_servers.select { |s| names.include?(AIA::Utility.server_name(s)) }
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/mcp_grouper.rb
4
+ #
5
+ # Groups MCP servers into independent sets that can run concurrently.
6
+ # Servers with the same `group` value must run sequentially.
7
+ # Servers without a group are each independent.
8
+
9
+ module AIA
10
+ class MCPGrouper
11
+ # Group MCP servers into independent sets.
12
+ #
13
+ # @param servers [Array<Hash>] MCP server configs
14
+ # @return [Array<Array<Hash>>] groups of servers that can run concurrently
15
+ def group(servers)
16
+ return [] if servers.nil? || servers.empty?
17
+
18
+ independent = []
19
+ groups = Hash.new { |h, k| h[k] = [] }
20
+
21
+ servers.each do |server|
22
+ group_name = server[:group] || server["group"]
23
+ if group_name
24
+ groups[group_name] << server
25
+ else
26
+ independent << [server] # Each independent server is its own group
27
+ end
28
+ end
29
+
30
+ independent + groups.values
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # lib/aia/mcp_server_config.rb
2
+
3
+ module AIA
4
+ # Normalized view of one MCP server hash. Server configs arrive with
5
+ # symbol or string keys, and with connection fields either nested under
6
+ # :transport or at the top level; this object owns all of that fallback
7
+ # logic so callers never touch the raw hash shape.
8
+ McpServerConfig = Data.define(:name, :command, :args, :env, :timeout_raw) do
9
+ def self.from_hash(server)
10
+ transport = fetch_key(server, :transport) || {}
11
+ new(
12
+ name: AIA::Utility.server_name(server),
13
+ command: fetch_key(transport, :command) || fetch_key(server, :command),
14
+ args: Array(fetch_key(transport, :args) || fetch_key(server, :args)),
15
+ env: fetch_key(transport, :env) || fetch_key(server, :env) || {},
16
+ timeout_raw: fetch_key(server, :timeout)
17
+ )
18
+ end
19
+
20
+ # Read a hash entry by symbol or string key.
21
+ def self.fetch_key(hash, key) = hash[key] || hash[key.to_s]
22
+
23
+ # Timeout in milliseconds; raw values below 1000 are treated as seconds.
24
+ def timeout_ms(default: 8_000, cap: 30_000)
25
+ raw = (timeout_raw || default).to_i
26
+ ms = raw < 1000 ? raw * 1000 : raw
27
+ [ms, cap].min
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/mcp_utility.rb
4
+ #
5
+ # Pure MCP state-query methods mixed into AIA::Utility via `class << self include`.
6
+ # No display or formatting concerns — those live in Utility directly.
7
+
8
+ module AIA
9
+ module MCPUtility
10
+ def mcp_servers?
11
+ effective_mcp_server_names.any?
12
+ end
13
+
14
+ def mcp_server_names
15
+ connected = AIA.config&.connected_mcp_servers
16
+ return connected unless connected.nil?
17
+ return RubyLLM::MCP.clients.keys if defined?(RubyLLM::MCP) && RubyLLM::MCP.clients.any?
18
+ effective_mcp_server_names
19
+ end
20
+
21
+ def connected_mcp_servers?
22
+ mcp_server_names.any?
23
+ end
24
+
25
+ def failed_mcp_servers
26
+ AIA.config&.failed_mcp_servers || []
27
+ end
28
+
29
+ # :reek:TooManyStatements -- server-name resolution applying no_mcp, use-list, and skip-list filters in precedence order
30
+ def effective_mcp_server_names
31
+ cfg = AIA.config
32
+ return [] if cfg&.flags&.no_mcp
33
+
34
+ servers = cfg&.mcp_servers || []
35
+ return [] if servers.empty?
36
+
37
+ names = servers.map { |s| server_name(s) }.compact
38
+ use_list = Array(cfg.mcp_use)
39
+ skip_list = Array(cfg.mcp_skip)
40
+
41
+ if use_list.any?
42
+ names.select { |n| use_list.include?(n) }
43
+ elsif skip_list.any?
44
+ names.reject { |n| skip_list.include?(n) }
45
+ else
46
+ names
47
+ end
48
+ end
49
+
50
+ def server_name(s)
51
+ if s.is_a?(Hash)
52
+ s[:name] || s['name']
53
+ elsif s.respond_to?(:name)
54
+ s.name
55
+ else
56
+ s.to_s
57
+ end
58
+ end
59
+ end
60
+ end