aia 1.1.0 → 2.0.0.0.pre.alpha

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +231 -0
  4. data/.quality/flay_baseline.txt +1 -0
  5. data/.quality/flog_baseline.txt +29 -0
  6. data/.quality/reek_baseline.txt +80 -0
  7. data/.rubocop.yml +116 -0
  8. data/.version +1 -1
  9. data/CHANGELOG.md +266 -42
  10. data/IMPLEMENTATION_PLAN.md +506 -0
  11. data/README.md +266 -238
  12. data/Rakefile +118 -5
  13. data/architecture_review.md +314 -0
  14. data/bin/aia +16 -0
  15. data/docs/AGENTS.md +40 -0
  16. data/docs/advanced-prompting.md +67 -3
  17. data/docs/cli-reference.md +312 -56
  18. data/docs/configuration.md +130 -19
  19. data/docs/contributing.md +56 -2
  20. data/docs/directives-reference.md +593 -78
  21. data/docs/faq.md +85 -3
  22. data/docs/guides/available-models.md +1 -1
  23. data/docs/guides/basic-usage.md +6 -6
  24. data/docs/guides/chat.md +40 -16
  25. data/docs/guides/crew.md +239 -0
  26. data/docs/guides/executable-prompts.md +1 -1
  27. data/docs/guides/index.md +1 -0
  28. data/docs/guides/models.md +15 -0
  29. data/docs/index.md +29 -2
  30. data/docs/installation.md +44 -17
  31. data/docs/mcp-integration.md +40 -0
  32. data/docs/prompt_management.md +85 -86
  33. data/docs/security.md +47 -0
  34. data/docs/special_projects_guide.md +386 -0
  35. data/docs/tools-and-mcp-examples.md +23 -0
  36. data/docs/workflows-and-pipelines.md +84 -7
  37. data/examples/.gitignore +1 -0
  38. data/examples/00_setup_aia.sh +27 -44
  39. data/examples/11_multi_model.sh +4 -14
  40. data/examples/12_token_usage.sh +3 -12
  41. data/examples/18_tools.sh +10 -2
  42. data/examples/22_chat_mode.sh +0 -10
  43. data/examples/23_verify.sh +139 -0
  44. data/examples/24_decompose.sh +139 -0
  45. data/examples/25_spawn.sh +139 -0
  46. data/examples/26_debate.sh +97 -0
  47. data/examples/27_mention_routing.sh +157 -0
  48. data/examples/28_model_switching.sh +106 -0
  49. data/examples/29_agent_harness.sh +177 -0
  50. data/examples/README.md +65 -0
  51. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  52. data/examples/aia_config.yml +1 -1
  53. data/examples/aia_config_orchestrator.yml +45 -0
  54. data/examples/common.sh +19 -0
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/project_summary +2 -2
  57. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  58. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  59. data/examples/rules/01_classify_ruby.rb +16 -0
  60. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  61. data/examples/rules/03_gate_prompt_length.rb +19 -0
  62. data/examples/rules/04_tool_selection.rb +41 -0
  63. data/examples/rules/README.md +30 -0
  64. data/examples/run_all.sh +48 -15
  65. data/examples/tools/word_count_tool.rb +1 -1
  66. data/lib/AGENTS.md +57 -0
  67. data/lib/aia/chat_loop.rb +306 -159
  68. data/lib/aia/config/cli_parser.rb +174 -111
  69. data/lib/aia/config/defaults.yml +62 -33
  70. data/lib/aia/config/mcp_parser.rb +39 -46
  71. data/lib/aia/config/model_spec.rb +34 -2
  72. data/lib/aia/config/validator.rb +121 -138
  73. data/lib/aia/config.rb +110 -145
  74. data/lib/aia/content_extractor.rb +153 -0
  75. data/lib/aia/cost_calculator.rb +38 -0
  76. data/lib/aia/crew.rb +164 -0
  77. data/lib/aia/debate_handler.rb +166 -0
  78. data/lib/aia/delegate_handler.rb +112 -0
  79. data/lib/aia/directive.rb +33 -18
  80. data/lib/aia/directive_processor.rb +16 -7
  81. data/lib/aia/directives/configuration_directives.rb +160 -20
  82. data/lib/aia/directives/context_directives.rb +38 -26
  83. data/lib/aia/directives/execution_directives.rb +136 -4
  84. data/lib/aia/directives/model_directives.rb +76 -34
  85. data/lib/aia/directives/trakflow_directives.rb +44 -0
  86. data/lib/aia/directives/utility_directives.rb +203 -6
  87. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  88. data/lib/aia/errors.rb +15 -0
  89. data/lib/aia/fact_asserter.rb +27 -0
  90. data/lib/aia/fzf.rb +9 -31
  91. data/lib/aia/handler_context.rb +17 -0
  92. data/lib/aia/handler_protocol.rb +19 -0
  93. data/lib/aia/history_transfer.rb +55 -0
  94. data/lib/aia/input_collector.rb +3 -3
  95. data/lib/aia/layered_orchestrator.rb +448 -0
  96. data/lib/aia/logger.rb +24 -4
  97. data/lib/aia/mcp_config_normalizer.rb +35 -0
  98. data/lib/aia/mcp_connection_manager.rb +305 -0
  99. data/lib/aia/mcp_discovery.rb +44 -0
  100. data/lib/aia/mcp_grouper.rb +33 -0
  101. data/lib/aia/mcp_utility.rb +57 -0
  102. data/lib/aia/mention_router.rb +260 -0
  103. data/lib/aia/model_alias_registry.rb +97 -0
  104. data/lib/aia/model_switch_handler.rb +100 -0
  105. data/lib/aia/network_builder.rb +155 -0
  106. data/lib/aia/network_memory_manager.rb +55 -0
  107. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  108. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  109. data/lib/aia/pipeline_orchestrator.rb +262 -0
  110. data/lib/aia/plugin_loader.rb +170 -0
  111. data/lib/aia/plugin_monitor.rb +208 -0
  112. data/lib/aia/prompt_decomposer.rb +157 -0
  113. data/lib/aia/prompt_handler.rb +19 -39
  114. data/lib/aia/robot_builder.rb +51 -0
  115. data/lib/aia/robot_factory.rb +334 -0
  116. data/lib/aia/robot_namer.rb +116 -0
  117. data/lib/aia/session.rb +83 -17
  118. data/lib/aia/session_tracker.rb +209 -0
  119. data/lib/aia/similarity_scorer.rb +39 -0
  120. data/lib/aia/skill_utils.rb +105 -1
  121. data/lib/aia/spawn_handler.rb +129 -0
  122. data/lib/aia/spawn_spec_parser.rb +65 -0
  123. data/lib/aia/special_mode_handler.rb +302 -0
  124. data/lib/aia/startup_coordinator.rb +150 -0
  125. data/lib/aia/streaming_runner.rb +169 -0
  126. data/lib/aia/system_prompt_assembler.rb +88 -0
  127. data/lib/aia/task_coordinator.rb +202 -0
  128. data/lib/aia/task_decomposer.rb +57 -0
  129. data/lib/aia/task_executor.rb +51 -0
  130. data/lib/aia/tfidf_math.rb +27 -0
  131. data/lib/aia/tool_filter/tfidf.rb +116 -0
  132. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  133. data/lib/aia/tool_filter.rb +82 -0
  134. data/lib/aia/tool_filter_registry.rb +30 -0
  135. data/lib/aia/tool_filter_strategy.rb +143 -0
  136. data/lib/aia/tool_loader.rb +210 -0
  137. data/lib/aia/tool_utility.rb +30 -0
  138. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  139. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  140. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  141. data/lib/aia/tools/task_board_tool.rb +114 -0
  142. data/lib/aia/trakflow_bridge.rb +173 -0
  143. data/lib/aia/turn_state.rb +94 -0
  144. data/lib/aia/ui_presenter.rb +166 -198
  145. data/lib/aia/utility.rb +134 -87
  146. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  147. data/lib/aia/verification_network.rb +58 -0
  148. data/lib/aia.rb +108 -63
  149. data/mkdocs.yml +1 -0
  150. metadata +179 -56
  151. data/justfile +0 -215
  152. data/lib/aia/adapter/chat_execution.rb +0 -242
  153. data/lib/aia/adapter/error_handler.rb +0 -68
  154. data/lib/aia/adapter/gem_activator.rb +0 -57
  155. data/lib/aia/adapter/mcp_connector.rb +0 -274
  156. data/lib/aia/adapter/modality_handlers.rb +0 -167
  157. data/lib/aia/adapter/model_registry.rb +0 -81
  158. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  159. data/lib/aia/adapter/provider_configurator.rb +0 -59
  160. data/lib/aia/adapter/tool_filter.rb +0 -85
  161. data/lib/aia/adapter/tool_loader.rb +0 -90
  162. data/lib/aia/chat_processor_service.rb +0 -164
  163. data/lib/aia/prompt_pipeline.rb +0 -183
  164. data/lib/aia/ruby_llm_adapter.rb +0 -95
  165. data/lib/extensions/openstruct_merge.rb +0 -48
  166. data/lib/extensions/ruby_llm/.irbrc +0 -56
  167. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  168. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  169. data/lib/refinements/string.rb +0 -16
  170. data/main.just +0 -76
@@ -0,0 +1,305 @@
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
+ class MCPConnectionManager
14
+ # Maximum seconds to wait for a single MCP server to connect.
15
+ DEFAULT_TIMEOUT = 30
16
+
17
+ attr_reader :connected_clients, :connected_tools, :failed_servers
18
+
19
+ def initialize
20
+ @connected_clients = {}
21
+ @connected_tools = []
22
+ @failed_servers = []
23
+ @server_tool_counts = {}
24
+ @connected = false
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ # Connect all configured MCP servers concurrently.
29
+ # Each server gets its own spinner line and thread.
30
+ #
31
+ # @param servers [Array<Hash>] MCP server configurations
32
+ # @return [self]
33
+ def connect_all(servers)
34
+ return self unless servers.is_a?(Array) && servers.any?
35
+
36
+ reset_connection_state
37
+ logger = AIA::LoggerManager.mcp_logger
38
+ server_names = servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
39
+ logger.info("MCP initialization: connecting #{servers.size} server(s): #{server_names.join(', ')}")
40
+
41
+ multi = TTY::Spinner::Multi.new("[:spinner] Connecting MCP servers", format: :dots, output: $stderr)
42
+ threads = launch_server_threads(servers, multi, logger)
43
+
44
+ # The global cap must never be smaller than the slowest single server's
45
+ # timeout, or concurrent connections get killed before they can finish.
46
+ global_limit = [global_connection_timeout, *servers.map { |s| server_timeout(s) }].max
47
+ Timeout.timeout(global_limit) { threads.each(&:join) }
48
+
49
+ @connected = true
50
+ logger.info("MCP initialization complete: #{@connected_clients.size} connected, #{@failed_servers.size} failed")
51
+ self
52
+ rescue Timeout::Error
53
+ handle_global_timeout(servers, global_limit)
54
+ end
55
+
56
+ # Inject connected MCP clients and tools into robot(s).
57
+ #
58
+ # @param robot [RobotLab::Robot, RobotLab::Network] the robot or network
59
+ # @return [self]
60
+ def inject_into(robot)
61
+ clients, tools = @mutex.synchronize { [@connected_clients.dup, @connected_tools.dup] }
62
+
63
+ targets = robot.crew
64
+
65
+ targets.each do |target|
66
+ target.inject_mcp!(clients: clients, tools: tools)
67
+ end
68
+
69
+ self
70
+ end
71
+
72
+ # Update AIA config with connection results.
73
+ #
74
+ # @return [self]
75
+ def update_config
76
+ connected_keys, tool_counts, failed = @mutex.synchronize do
77
+ [@connected_clients.keys.dup, @server_tool_counts.dup, @failed_servers.dup]
78
+ end
79
+ AIA.config.connected_mcp_servers = connected_keys
80
+ AIA.config.mcp_server_tool_counts = tool_counts
81
+ AIA.config.failed_mcp_servers = failed
82
+ self
83
+ end
84
+
85
+ # Whether connect_all has been called.
86
+ def connected?
87
+ @connected
88
+ end
89
+
90
+ # Whether any tools were collected (from config servers or Ruby require).
91
+ def any_tools?
92
+ @mutex.synchronize { @connected_tools.any? }
93
+ end
94
+
95
+ # Absorb MCP clients registered in RubyLLM::MCP (e.g., via --require loading
96
+ # shared_tools/mcp/* files) that are not already tracked by this manager.
97
+ # Starts any unstarted clients, extracts their tools, and merges them into
98
+ # the connected state so inject_into will include them in the robot.
99
+ #
100
+ # @return [self]
101
+ def absorb_ruby_llm_mcp_clients
102
+ return self unless defined?(RubyLLM::MCP)
103
+
104
+ logger = AIA::LoggerManager.mcp_logger
105
+
106
+ RubyLLM::MCP.clients.each do |name, client|
107
+ next if @connected_clients.key?(name)
108
+
109
+ begin
110
+ client.start unless client.alive?
111
+
112
+ if client.alive?
113
+ tools = client.tools rescue []
114
+ @mutex.synchronize do
115
+ @connected_clients[name] = client
116
+ @server_tool_counts[name] = tools.size
117
+ @connected_tools.concat(tools)
118
+ end
119
+ logger.info("MCP: absorbed RubyLLM::MCP client '#{name}' (#{tools.size} tools)")
120
+ else
121
+ logger.warn("MCP: RubyLLM::MCP client '#{name}' not alive, skipping")
122
+ end
123
+ rescue StandardError => e
124
+ logger.warn("MCP: error absorbing RubyLLM::MCP client '#{name}': #{e.message}")
125
+ end
126
+ end
127
+
128
+ @connected = true unless RubyLLM::MCP.clients.empty?
129
+ self
130
+ end
131
+
132
+ # Find an MCP client by server name.
133
+ #
134
+ # @param name [String] server name
135
+ # @return [RobotLab::MCP::Client, nil]
136
+ def client(name)
137
+ @connected_clients[name]
138
+ end
139
+
140
+ # Connected server names.
141
+ #
142
+ # @return [Array<String>]
143
+ def connected_server_names
144
+ @mutex.synchronize { @connected_clients.keys.dup }
145
+ end
146
+
147
+ # Failed server names.
148
+ #
149
+ # @return [Array<String>]
150
+ def failed_server_names
151
+ @mutex.synchronize { @failed_servers.map { |f| f[:name] } }
152
+ end
153
+
154
+ # Close all connected MCP clients and reset connection state.
155
+ # Errors from individual client closes are silently rescued —
156
+ # best-effort cleanup on process exit.
157
+ def close_all
158
+ clients = @mutex.synchronize { @connected_clients.dup }
159
+ clients.each_value do |client|
160
+ client.close
161
+ rescue StandardError
162
+ # best-effort — client may already be closed
163
+ end
164
+ @mutex.synchronize do
165
+ @connected_clients = {}
166
+ @connected_tools = []
167
+ @server_tool_counts = {}
168
+ @connected = false
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def reset_connection_state
175
+ @connected_clients = {}
176
+ @connected_tools = []
177
+ @failed_servers = []
178
+ end
179
+
180
+ def launch_server_threads(servers, multi, logger)
181
+ servers.map do |server_config|
182
+ name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
183
+ spinner = multi.register("[:spinner] #{name}")
184
+ Thread.new(server_config, name, spinner) { |cfg, srv_name, sp| connect_one(cfg, srv_name, sp, logger) }
185
+ end
186
+ end
187
+
188
+ def handle_global_timeout(servers, global_limit)
189
+ logger = AIA::LoggerManager.mcp_logger
190
+ logger.warn("MCP: Global connection timeout (#{global_limit}s) reached")
191
+
192
+ connected_names = @mutex.synchronize { @connected_clients.keys.to_set }
193
+ failed_names = @mutex.synchronize { @failed_servers.to_set { |f| f[:name] } }
194
+
195
+ servers.each do |server_config|
196
+ srv_name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
197
+ next if connected_names.include?(srv_name) || failed_names.include?(srv_name)
198
+ @mutex.synchronize { @failed_servers << { name: srv_name, error: "global connection timeout (#{global_limit}s)" } }
199
+ end
200
+ end
201
+
202
+ # Read global MCP connection timeout from config.
203
+ # Falls back to 120s if config is unavailable.
204
+ def global_connection_timeout
205
+ AIA.config&.concurrency&.mcp_timeout.to_i.then { |v| v.positive? ? v : 120 }
206
+ rescue StandardError
207
+ 120
208
+ end
209
+
210
+ # Connect a single MCP server, updating the spinner on completion.
211
+ # rubocop:disable Metrics/MethodLength
212
+ def connect_one(server_config, name, spinner, logger)
213
+ timeout = server_timeout(server_config)
214
+ spinner.auto_spin
215
+
216
+ logger.info("MCP: connecting to '#{name}'...")
217
+ # rubocop:disable Metrics/BlockLength
218
+ Timeout.timeout(timeout) do
219
+ client = RobotLab::MCP::Client.new(server_config)
220
+ client.connect
221
+
222
+ if client.connected?
223
+ tools = client.list_tools
224
+ built_tools = tools.map do |tool_def|
225
+ tool_name = tool_def[:name]
226
+ mcp_client = client
227
+ RobotLab::Tool.create(
228
+ name: tool_name,
229
+ description: tool_def[:description],
230
+ parameters: tool_def[:inputSchema],
231
+ mcp: name
232
+ ) { |args| mcp_client.call_tool(tool_name, args) }
233
+ end
234
+
235
+ @mutex.synchronize do
236
+ @connected_clients[name] = client
237
+ @server_tool_counts[name] = tools.size
238
+ add_tools_deduped(built_tools, name, logger)
239
+ end
240
+
241
+ logger.info("MCP: '#{name}' connected (#{tools.size} tools)")
242
+ spinner.success("(#{tools.size} tools)")
243
+ else
244
+ @mutex.synchronize do
245
+ @failed_servers << { name: name, error: "connection failed" }
246
+ end
247
+ logger.warn("MCP: '#{name}' failed to connect")
248
+ spinner.error("(connection failed)")
249
+ end
250
+ end
251
+ # rubocop:enable Metrics/BlockLength
252
+ rescue Timeout::Error
253
+ @mutex.synchronize do
254
+ @failed_servers << { name: name, error: "timed out after #{timeout}s" }
255
+ end
256
+ logger.warn("MCP: '#{name}' timed out after #{timeout}s")
257
+ spinner.error("(timed out)")
258
+ rescue StandardError => e
259
+ @mutex.synchronize do
260
+ @failed_servers << { name: name, error: e.message }
261
+ end
262
+ logger.warn("MCP: '#{name}' error: #{e.message}")
263
+ spinner.error("(#{e.message})")
264
+ end
265
+ # rubocop:enable Metrics/MethodLength
266
+
267
+ # Add tools to @connected_tools, skipping any whose name is already present.
268
+ # Logs a warning for each duplicate so the user knows which server wins.
269
+ # MUST be called inside @mutex.synchronize.
270
+ #
271
+ # @param new_tools [Array] tools to add
272
+ # @param server_name [String] name of the server providing these tools (for warning)
273
+ # @param logger [Logger] MCP logger
274
+ def add_tools_deduped(new_tools, server_name, logger)
275
+ new_tools.each do |tool|
276
+ tool_name = tool.respond_to?(:name) ? tool.name : tool.to_s
277
+ existing = @connected_tools.find do |t|
278
+ (t.respond_to?(:name) ? t.name : t.to_s) == tool_name
279
+ end
280
+
281
+ if existing
282
+ logger.warn("MCP: Tool '#{tool_name}' already registered from another server. " \
283
+ "Skipping duplicate from '#{server_name}'.")
284
+ else
285
+ @connected_tools << tool
286
+ end
287
+ end
288
+ end
289
+
290
+ # Extract timeout in seconds from server config.
291
+ # Config values >= 1000 are treated as milliseconds.
292
+ def server_timeout(server_config)
293
+ raw = server_config.is_a?(Hash) ? server_config[:timeout] : nil
294
+ return DEFAULT_TIMEOUT if raw.nil?
295
+
296
+ seconds = raw.to_f
297
+ seconds /= 1000.0 if seconds >= 1000 # values >= 1000 are milliseconds
298
+ # Honor the configured per-server timeout. DEFAULT_TIMEOUT is only the
299
+ # fallback when no timeout is set (handled above) — it must NOT cap a
300
+ # larger configured value, or slow-starting servers (npx/uvx cold starts)
301
+ # get dropped and their tools never reach the robot or the tool filter.
302
+ seconds.positive? ? seconds : DEFAULT_TIMEOUT
303
+ end
304
+ end
305
+ 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,57 @@
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
+ def effective_mcp_server_names
30
+ return [] if AIA.config&.flags&.no_mcp
31
+ servers = AIA.config&.mcp_servers || []
32
+ return [] if servers.empty?
33
+
34
+ names = servers.map { |s| server_name(s) }.compact
35
+ use_list = Array(AIA.config.mcp_use)
36
+ skip_list = Array(AIA.config.mcp_skip)
37
+
38
+ if use_list.any?
39
+ names.select { |n| use_list.include?(n) }
40
+ elsif skip_list.any?
41
+ names.reject { |n| skip_list.include?(n) }
42
+ else
43
+ names
44
+ end
45
+ end
46
+
47
+ def server_name(s)
48
+ if s.is_a?(Hash)
49
+ s[:name] || s['name']
50
+ elsif s.respond_to?(:name)
51
+ s.name
52
+ else
53
+ s.to_s
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/mention_router.rb
4
+ #
5
+ # Handles @mention routing in multi-model networks.
6
+ # Scans prompts for @name tokens, matches them to robots in the
7
+ # network, and sends the prompt only to mentioned robots.
8
+
9
+ module AIA
10
+ class MentionRouter
11
+ include ContentExtractor
12
+ include HandlerProtocol
13
+
14
+ # Reserved @mention that broadcasts the message to every crew member.
15
+ BROADCAST_TOKEN = 'crew'
16
+
17
+ def initialize(ui_presenter:, tracker:, streaming_runner:)
18
+ @ui_presenter = ui_presenter
19
+ @tracker = tracker
20
+ @streaming_runner = streaming_runner
21
+ end
22
+
23
+ # Scan prompt for @mentions and route to matching robots.
24
+ # Returns true if mentions were handled, false otherwise.
25
+ #
26
+ # @param context [HandlerContext] — reads context.robot and context.prompt
27
+ # @return [Boolean]
28
+ def handle(context) # rubocop:disable Naming/PredicateMethod
29
+ robot = context.robot
30
+ prompt = context.prompt
31
+ return false unless robot.network?
32
+
33
+ mention_tokens = prompt.scan(/@(\w+)/i).flatten
34
+ return false if mention_tokens.empty?
35
+
36
+ all_robots = robot.crew
37
+ matched = resolve_mentions(mention_tokens, all_robots)
38
+
39
+ if matched.empty?
40
+ report_unknown_mentions(mention_tokens, all_robots)
41
+ return true
42
+ end
43
+
44
+ clean_prompt = strip_addressing_prefix(prompt)
45
+
46
+ report_unknown_mentions(mention_tokens, all_robots)
47
+ run_mentioned_robots(matched, clean_prompt, concurrent: leading_address_only?(prompt))
48
+ true
49
+ end
50
+
51
+ private
52
+
53
+ # Remove only the leading run of @mentions (the addressing prefix), so
54
+ # "@alice summarize this" becomes "summarize this" while @mentions inside the
55
+ # message body — "hello @hemie have you met @joker" — are preserved as
56
+ # content and reach the routed robots intact.
57
+ def strip_addressing_prefix(prompt)
58
+ prompt.sub(/\A(?:\s*@\w+)+\s*/i, '').strip
59
+ end
60
+
61
+ # True when every @mention is part of the leading address — i.e. nothing but
62
+ # @mentions before the message — so it's a broadcast to its addressees and
63
+ # runs concurrently. A @mention woven into the body makes it sequential.
64
+ def leading_address_only?(prompt)
65
+ !strip_addressing_prefix(prompt).match?(/@\w+/)
66
+ end
67
+
68
+ # Resolve @mention tokens to robots. The reserved @crew token broadcasts to
69
+ # the entire crew; otherwise each token is matched to a robot by name.
70
+ #
71
+ # @return [Array<Robot>]
72
+ def resolve_mentions(tokens, all_robots)
73
+ return all_robots if tokens.any? { |token| token.casecmp?(BROADCAST_TOKEN) }
74
+
75
+ tokens.filter_map { |token| all_robots.find { |r| r.name.casecmp?(token) } }.uniq(&:name)
76
+ end
77
+
78
+ def report_unknown_mentions(mention_tokens, all_robots)
79
+ known_names = all_robots.map { |r| r.name.downcase }
80
+ unknown = mention_tokens.reject { |t| t.casecmp?(BROADCAST_TOKEN) || known_names.include?(t.downcase) }
81
+ return unless unknown.any?
82
+
83
+ available = all_robots.map(&:name).join(', ')
84
+ unknown.each do |name|
85
+ @ui_presenter.display_info("Unknown robot: @#{name} (available: #{available})")
86
+ end
87
+ end
88
+
89
+ def run_mentioned_robots(robots, prompt, concurrent:)
90
+ if robots.size == 1
91
+ run_streamed(robots.first, prompt)
92
+ elsif concurrent
93
+ run_concurrently(robots, prompt)
94
+ else
95
+ run_sequentially(robots, prompt)
96
+ end
97
+ @ui_presenter.display_separator
98
+ end
99
+
100
+ # Robots mentioned in the body run one at a time, each streaming its reply.
101
+ # After each reply, it is injected into every *other* member's conversation
102
+ # so later robots build on it and earlier ones carry it forward — the
103
+ # sequential pipeline becomes a shared, accumulating conversation.
104
+ def run_sequentially(robots, prompt)
105
+ robots.each do |bot|
106
+ content = run_streamed(bot, prompt)
107
+ share_with_peers(robots, bot, content) if content
108
+ end
109
+ end
110
+
111
+ # Add one robot's reply to every other pipeline member's chat history.
112
+ def share_with_peers(robots, author, content)
113
+ note = "[#{author.name}]: #{content}"
114
+ (robots - [author]).each do |peer|
115
+ next unless peer.respond_to?(:chat) && peer.chat
116
+
117
+ peer.chat.add_message({ role: :user, content: note })
118
+ end
119
+ end
120
+
121
+ # A single addressed robot streams its reply token-by-token.
122
+ def run_streamed(bot, prompt)
123
+ result, streamed_content, elapsed = @streaming_runner.run(
124
+ bot, prompt,
125
+ header: "\nAI (#{bot.name}):\n ",
126
+ spinner_message: "#{bot.name} processing..."
127
+ )
128
+ content = streamed_content || extract_content(result)
129
+
130
+ if streamed_content
131
+ puts
132
+ @ui_presenter.display_info("(#{format_duration(elapsed)})")
133
+ else
134
+ @ui_presenter.display_ai_response(reply_block(bot, content, elapsed))
135
+ end
136
+
137
+ finalize_reply(bot, prompt, result, content, elapsed)
138
+ content
139
+ rescue StandardError => e
140
+ @ui_presenter.display_info("Error from #{bot.name}: #{e.class}: #{e.message}")
141
+ nil
142
+ end
143
+
144
+ # A broadcast to multiple robots runs them concurrently — no token streaming,
145
+ # which would interleave on one console. Each robot runs in its own thread and
146
+ # pushes its result to a queue; the main thread renders each reply as it lands
147
+ # (finish order), so wall-clock is the slowest robot, not the sum. Rendering
148
+ # stays single-threaded, so display/tracking are never touched concurrently.
149
+ def run_concurrently(robots, prompt)
150
+ @ui_presenter.display_info("Broadcasting to #{robots.size} robots concurrently…")
151
+
152
+ done = Queue.new
153
+ robots.each { |bot| Thread.new { done << run_member(bot, prompt) } }
154
+ robots.size.times { render_member(prompt, *done.pop) }
155
+ end
156
+
157
+ # Run one crew member to completion (off the streaming path), in a worker
158
+ # thread. Returns [bot, result, elapsed, error] — no shared state touched.
159
+ def run_member(bot, prompt)
160
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
+ result = bot.run(prompt, mcp: :inherit, tools: :inherit)
162
+ [bot, result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, nil]
163
+ rescue StandardError => e
164
+ [bot, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, e]
165
+ end
166
+
167
+ # Render one crew member's reply on the main thread.
168
+ def render_member(prompt, bot, result, elapsed, error)
169
+ if error
170
+ @ui_presenter.display_info("Error from #{bot.name}: #{error.class}: #{error.message}")
171
+ return
172
+ end
173
+
174
+ content = extract_content(result)
175
+ @ui_presenter.display_ai_response(reply_block(bot, content, elapsed))
176
+ finalize_reply(bot, prompt, result, content, elapsed)
177
+ end
178
+
179
+ def reply_block(bot, content, elapsed)
180
+ "**#{bot.name}** [#{bot.model || 'unknown'}] (#{format_duration(elapsed)}):\n#{content}"
181
+ end
182
+
183
+ def finalize_reply(bot, prompt, result, content, elapsed)
184
+ @tracker.record_turn(model: bot.model || 'unknown', input: prompt, result: result, elapsed: elapsed)
185
+ output_to_file(content)
186
+ display_metrics(result, elapsed: elapsed)
187
+ speak(content)
188
+ end
189
+
190
+ def display_metrics(result, elapsed: nil)
191
+ return unless AIA.config.flags.tokens
192
+
193
+ raw = result.respond_to?(:raw) ? result.raw : nil
194
+ return unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
195
+
196
+ model_id = extract_model_id(raw)
197
+ model_id ||= result.respond_to?(:robot_name) ? result.robot_name : "unknown"
198
+ metrics = {
199
+ model_id: model_id,
200
+ input_tokens: raw.input_tokens,
201
+ output_tokens: raw.output_tokens,
202
+ elapsed: elapsed
203
+ }
204
+ @ui_presenter.display_token_metrics(metrics)
205
+ end
206
+
207
+ def extract_model_id(message)
208
+ return message.model_id if message.respond_to?(:model_id) && message.model_id
209
+ return message.model if message.respond_to?(:model) && message.model
210
+ nil
211
+ end
212
+
213
+ def speak(content)
214
+ return unless AIA.speak?
215
+
216
+ audio = AIA.config.audio
217
+ command = audio.speak_command || 'say'
218
+ env = {}
219
+ env['SPEECH_MODEL'] = audio.speech_model if audio.speech_model
220
+
221
+ if command == 'say'
222
+ run_with_spinner("Speaking...") do
223
+ if audio.voice && !audio.voice.to_s.strip.empty?
224
+ system(env, command, '-v', audio.voice, content.to_s)
225
+ else
226
+ system(env, command, content.to_s)
227
+ end
228
+ end
229
+ else
230
+ require 'tempfile'
231
+ tmpfile = Tempfile.new(['aia-tts-', '.mp3'])
232
+ tmpfile.close
233
+ begin
234
+ run_with_spinner("Converting to audio...") do
235
+ system(env, command, content.to_s, tmpfile.path)
236
+ end
237
+ if File.size?(tmpfile.path)
238
+ run_with_spinner("Speaking...") do
239
+ system('afplay', tmpfile.path)
240
+ end
241
+ end
242
+ ensure
243
+ tmpfile.unlink
244
+ end
245
+ end
246
+ rescue StandardError => e
247
+ $stderr.puts "Warning: Speech failed: #{e.message}"
248
+ end
249
+
250
+ def run_with_spinner(message)
251
+ spinner = TTY::Spinner.new("[:spinner] #{message}", format: :bouncing_ball, output: $stderr)
252
+ spinner.auto_spin
253
+ begin
254
+ yield
255
+ ensure
256
+ spinner.stop
257
+ end
258
+ end
259
+ end
260
+ end