aia 1.1.1 → 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 (169) 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 +259 -50
  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 +18 -6
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  57. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  58. data/examples/rules/01_classify_ruby.rb +16 -0
  59. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  60. data/examples/rules/03_gate_prompt_length.rb +19 -0
  61. data/examples/rules/04_tool_selection.rb +41 -0
  62. data/examples/rules/README.md +30 -0
  63. data/examples/run_all.sh +48 -15
  64. data/examples/tools/word_count_tool.rb +1 -1
  65. data/lib/AGENTS.md +57 -0
  66. data/lib/aia/chat_loop.rb +304 -164
  67. data/lib/aia/config/cli_parser.rb +174 -111
  68. data/lib/aia/config/defaults.yml +62 -33
  69. data/lib/aia/config/mcp_parser.rb +39 -46
  70. data/lib/aia/config/model_spec.rb +34 -2
  71. data/lib/aia/config/validator.rb +108 -142
  72. data/lib/aia/config.rb +110 -145
  73. data/lib/aia/content_extractor.rb +153 -0
  74. data/lib/aia/cost_calculator.rb +38 -0
  75. data/lib/aia/crew.rb +164 -0
  76. data/lib/aia/debate_handler.rb +166 -0
  77. data/lib/aia/delegate_handler.rb +112 -0
  78. data/lib/aia/directive.rb +33 -18
  79. data/lib/aia/directive_processor.rb +16 -7
  80. data/lib/aia/directives/configuration_directives.rb +160 -20
  81. data/lib/aia/directives/context_directives.rb +38 -26
  82. data/lib/aia/directives/execution_directives.rb +136 -4
  83. data/lib/aia/directives/model_directives.rb +76 -34
  84. data/lib/aia/directives/trakflow_directives.rb +44 -0
  85. data/lib/aia/directives/utility_directives.rb +203 -6
  86. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  87. data/lib/aia/errors.rb +15 -0
  88. data/lib/aia/fact_asserter.rb +27 -0
  89. data/lib/aia/fzf.rb +9 -31
  90. data/lib/aia/handler_context.rb +17 -0
  91. data/lib/aia/handler_protocol.rb +19 -0
  92. data/lib/aia/history_transfer.rb +55 -0
  93. data/lib/aia/input_collector.rb +3 -3
  94. data/lib/aia/layered_orchestrator.rb +448 -0
  95. data/lib/aia/logger.rb +24 -4
  96. data/lib/aia/mcp_config_normalizer.rb +35 -0
  97. data/lib/aia/mcp_connection_manager.rb +305 -0
  98. data/lib/aia/mcp_discovery.rb +44 -0
  99. data/lib/aia/mcp_grouper.rb +33 -0
  100. data/lib/aia/mcp_utility.rb +57 -0
  101. data/lib/aia/mention_router.rb +260 -0
  102. data/lib/aia/model_alias_registry.rb +97 -0
  103. data/lib/aia/model_switch_handler.rb +100 -0
  104. data/lib/aia/network_builder.rb +155 -0
  105. data/lib/aia/network_memory_manager.rb +55 -0
  106. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  107. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  108. data/lib/aia/pipeline_orchestrator.rb +262 -0
  109. data/lib/aia/plugin_loader.rb +170 -0
  110. data/lib/aia/plugin_monitor.rb +208 -0
  111. data/lib/aia/prompt_decomposer.rb +157 -0
  112. data/lib/aia/prompt_handler.rb +19 -39
  113. data/lib/aia/robot_builder.rb +51 -0
  114. data/lib/aia/robot_factory.rb +334 -0
  115. data/lib/aia/robot_namer.rb +116 -0
  116. data/lib/aia/session.rb +83 -17
  117. data/lib/aia/session_tracker.rb +209 -0
  118. data/lib/aia/similarity_scorer.rb +39 -0
  119. data/lib/aia/skill_utils.rb +105 -1
  120. data/lib/aia/spawn_handler.rb +129 -0
  121. data/lib/aia/spawn_spec_parser.rb +65 -0
  122. data/lib/aia/special_mode_handler.rb +302 -0
  123. data/lib/aia/startup_coordinator.rb +150 -0
  124. data/lib/aia/streaming_runner.rb +169 -0
  125. data/lib/aia/system_prompt_assembler.rb +88 -0
  126. data/lib/aia/task_coordinator.rb +202 -0
  127. data/lib/aia/task_decomposer.rb +57 -0
  128. data/lib/aia/task_executor.rb +51 -0
  129. data/lib/aia/tfidf_math.rb +27 -0
  130. data/lib/aia/tool_filter/tfidf.rb +116 -0
  131. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  132. data/lib/aia/tool_filter.rb +82 -0
  133. data/lib/aia/tool_filter_registry.rb +30 -0
  134. data/lib/aia/tool_filter_strategy.rb +143 -0
  135. data/lib/aia/tool_loader.rb +210 -0
  136. data/lib/aia/tool_utility.rb +30 -0
  137. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  138. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  139. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  140. data/lib/aia/tools/task_board_tool.rb +114 -0
  141. data/lib/aia/trakflow_bridge.rb +173 -0
  142. data/lib/aia/turn_state.rb +94 -0
  143. data/lib/aia/ui_presenter.rb +166 -198
  144. data/lib/aia/utility.rb +134 -87
  145. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  146. data/lib/aia/verification_network.rb +58 -0
  147. data/lib/aia.rb +108 -63
  148. data/mkdocs.yml +1 -0
  149. metadata +179 -56
  150. data/justfile +0 -215
  151. data/lib/aia/adapter/chat_execution.rb +0 -242
  152. data/lib/aia/adapter/error_handler.rb +0 -68
  153. data/lib/aia/adapter/gem_activator.rb +0 -57
  154. data/lib/aia/adapter/mcp_connector.rb +0 -274
  155. data/lib/aia/adapter/modality_handlers.rb +0 -167
  156. data/lib/aia/adapter/model_registry.rb +0 -81
  157. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  158. data/lib/aia/adapter/provider_configurator.rb +0 -59
  159. data/lib/aia/adapter/tool_filter.rb +0 -85
  160. data/lib/aia/adapter/tool_loader.rb +0 -90
  161. data/lib/aia/chat_processor_service.rb +0 -178
  162. data/lib/aia/prompt_pipeline.rb +0 -183
  163. data/lib/aia/ruby_llm_adapter.rb +0 -95
  164. data/lib/extensions/openstruct_merge.rb +0 -48
  165. data/lib/extensions/ruby_llm/.irbrc +0 -56
  166. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  167. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  168. data/lib/refinements/string.rb +0 -16
  169. data/main.just +0 -76
@@ -1,242 +0,0 @@
1
- # lib/aia/adapter/chat_execution.rb
2
- # frozen_string_literal: true
3
-
4
- module AIA
5
- module Adapter
6
- module ChatExecution
7
- def chat(prompt)
8
- if @models.size == 1
9
- single_model_chat(prompt, @models.first)
10
- else
11
- multi_model_chat(prompt)
12
- end
13
- end
14
-
15
- def single_model_chat(prompt, internal_id)
16
- chat_instance = @chats[internal_id]
17
- modes = chat_instance.model.modalities
18
-
19
- # TODO: Need to consider how to handle multi-mode models
20
- if modes.text_to_text?
21
- text_to_text_single(prompt, internal_id)
22
- elsif modes.image_to_text?
23
- image_to_text_single(prompt, internal_id)
24
- elsif modes.text_to_image?
25
- text_to_image_single(prompt, internal_id)
26
- elsif modes.text_to_audio?
27
- text_to_audio_single(prompt, internal_id)
28
- elsif modes.audio_to_text?
29
- audio_to_text_single(prompt, internal_id)
30
- else
31
- # TODO: what else can be done?
32
- "Error: No matching modality for model #{internal_id}"
33
- end
34
- end
35
-
36
- # Clear the chat context/history
37
- # Needed for the /clear and /restore directives
38
- # Simplified with ADR-002: Each model has isolated context, no global state to manage
39
- def clear_context
40
- old_chats = @chats.dup
41
- new_chats = {}
42
-
43
- @models.each do |model_name|
44
- begin
45
- # Get the isolated context for this model
46
- context = @contexts[model_name]
47
- actual_model, provider = extract_model_and_provider(model_name)
48
-
49
- # Create a fresh chat instance from the same isolated context
50
- chat = if provider
51
- context.chat(model: actual_model, provider: provider, assume_model_exists: true)
52
- else
53
- context.chat(model: actual_model)
54
- end
55
-
56
- # Re-add tools if they were previously loaded
57
- if @tools && !@tools.empty? && chat.model&.supports_functions?
58
- chat.with_tools(*@tools)
59
- end
60
-
61
- new_chats[model_name] = chat
62
- rescue StandardError => e
63
- # If recreation fails, keep the old chat but clear its messages
64
- warn "Warning: Could not recreate chat for #{model_name}: #{e.message}. Clearing existing chat."
65
- chat = old_chats[model_name]
66
- if chat&.instance_variable_defined?(:@messages)
67
- chat.instance_variable_set(:@messages, [])
68
- end
69
- chat.clear_history if chat&.respond_to?(:clear_history)
70
- new_chats[model_name] = chat
71
- end
72
- end
73
-
74
- @chats = new_chats
75
- 'Chat context successfully cleared.'
76
- rescue StandardError => e
77
- "Error clearing chat context: #{e.message}"
78
- end
79
-
80
- def setup_chats_with_tools
81
- valid_chats = {}
82
- valid_contexts = {}
83
- valid_specs = []
84
- failed_models = []
85
-
86
- @model_specs.each do |spec|
87
- model_name = spec[:model] # Actual model name (e.g., "gpt-4o")
88
- internal_id = spec[:internal_id] # Key for storage (e.g., "gpt-4o#1", "gpt-4o#2")
89
-
90
- begin
91
- # Create isolated context for this model to prevent cross-talk (ADR-002)
92
- context = create_isolated_context_for_model(model_name)
93
-
94
- # Determine provider and actual model name
95
- actual_model, provider = extract_model_and_provider(model_name)
96
-
97
- # Validate LM Studio models
98
- if model_name.start_with?('lms/')
99
- lms_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
100
- validate_lms_model!(actual_model, lms_api_base)
101
- end
102
-
103
- # Create chat using isolated context
104
- chat = if provider
105
- context.chat(model: actual_model, provider: provider, assume_model_exists: true)
106
- else
107
- context.chat(model: actual_model)
108
- end
109
-
110
- valid_chats[internal_id] = chat
111
- valid_contexts[internal_id] = context
112
- valid_specs << spec
113
- rescue StandardError => e
114
- failed_models << "#{internal_id}: #{e.message}"
115
- end
116
- end
117
-
118
- # Report failed models but continue with valid ones
119
- unless failed_models.empty?
120
- warn "\nFailed to initialize the following models:"
121
- failed_models.each { |failure| warn " - #{failure}" }
122
- logger = LoggerManager.aia_logger
123
- failed_models.each { |failure| logger.error("Model initialization failed: #{failure}") }
124
- end
125
-
126
- # If no models initialized successfully, exit
127
- if valid_chats.empty?
128
- warn "\nNo valid models could be initialized. Exiting."
129
- warn "\nAvailable models can be listed with: bin/aia --help models"
130
- exit 1
131
- end
132
-
133
- @chats = valid_chats
134
- @contexts = valid_contexts
135
- @model_specs = valid_specs
136
- @models = valid_chats.keys
137
-
138
- # Report successful models
139
- if failed_models.any?
140
- warn "\nSuccessfully initialized: #{@models.join(', ')}"
141
- end
142
-
143
- # Use the first chat to determine tool support (assuming all models have similar tool support)
144
- first_chat = @chats.values.first
145
- return unless first_chat&.model&.supports_functions?
146
-
147
- load_tools_lazy_mcp_support_only_when_needed
148
-
149
- @chats.each_value do |chat|
150
- chat.with_tools(*tools) unless tools.empty?
151
- end
152
- end
153
-
154
- # Create an isolated RubyLLM::Context for a model to prevent cross-talk (ADR-002)
155
- # Each model gets its own context with provider-specific configuration
156
- def create_isolated_context_for_model(model_name)
157
- config = RubyLLM.config.dup
158
-
159
- # Apply provider-specific configuration
160
- if model_name.start_with?('lms/')
161
- config.openai_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
162
- config.openai_api_key = 'dummy' # Local servers don't need a real API key
163
- elsif model_name.start_with?('osaurus/')
164
- config.openai_api_base = ENV.fetch('OSAURUS_API_BASE', 'http://localhost:11434/v1')
165
- config.openai_api_key = 'dummy' # Local servers don't need a real API key
166
- end
167
-
168
- RubyLLM::Context.new(config)
169
- end
170
-
171
- # Extract the actual model name and provider from the prefixed model_name
172
- # Returns: [actual_model, provider] where provider may be nil for auto-detection
173
- def extract_model_and_provider(model_name)
174
- if model_name.start_with?('ollama/')
175
- [model_name.sub('ollama/', ''), 'ollama']
176
- elsif model_name.start_with?('lms/') || model_name.start_with?('osaurus/')
177
- [model_name.sub(%r{^(lms|osaurus)/}, ''), 'openai']
178
- else
179
- [model_name, nil] # Let RubyLLM auto-detect provider
180
- end
181
- end
182
-
183
- def validate_lms_model!(model_name, api_base)
184
- require 'net/http'
185
- require 'json'
186
-
187
- # Build the /v1/models endpoint URL
188
- uri = URI("#{api_base.gsub(%r{/v1/?$}, '')}/v1/models")
189
-
190
- begin
191
- response = Net::HTTP.get_response(uri)
192
-
193
- unless response.is_a?(Net::HTTPSuccess)
194
- raise "Cannot connect to LM Studio at #{api_base}. Is LM Studio running?"
195
- end
196
-
197
- data = JSON.parse(response.body)
198
- available_models = data['data']&.map { |m| m['id'] } || []
199
-
200
- unless available_models.include?(model_name)
201
- error_msg = "'#{model_name}' is not a valid LM Studio model.\n\n"
202
- if available_models.empty?
203
- error_msg += "No models are currently loaded in LM Studio.\n"
204
- error_msg += "Please load a model in LM Studio first."
205
- else
206
- error_msg += "Available LM Studio models:\n"
207
- available_models.each { |m| error_msg += " - lms/#{m}\n" }
208
- end
209
- raise error_msg
210
- end
211
- rescue JSON::ParserError => e
212
- raise "Invalid response from LM Studio at #{api_base}: #{e.message}"
213
- rescue StandardError => e
214
- # Re-raise our custom error messages, wrap others
215
- raise if e.message.include?("not a valid LM Studio") || e.message.include?("Cannot connect")
216
- raise "Error connecting to LM Studio: #{e.message}"
217
- end
218
- end
219
-
220
- private
221
-
222
- def load_tools_lazy_mcp_support_only_when_needed
223
- mcp_connector = McpConnector.new
224
- tool_loader = ToolLoader.new(mcp_connector)
225
-
226
- @tools = tool_loader.load_tools
227
- ToolFilter.detect_conflicts
228
- @tools = ToolFilter.filter_allowed(@tools)
229
- @tools = ToolFilter.filter_rejected(@tools)
230
- @tools = ToolFilter.drop_duplicates(@tools)
231
-
232
- if tools.empty?
233
- AIA.config.tool_names = ''
234
- AIA.config.loaded_tools = []
235
- else
236
- AIA.config.tool_names = @tools.map(&:name).join(', ')
237
- AIA.config.loaded_tools = @tools
238
- end
239
- end
240
- end
241
- end
242
- end
@@ -1,68 +0,0 @@
1
- # lib/aia/adapter/error_handler.rb
2
- # frozen_string_literal: true
3
-
4
- module AIA
5
- module Adapter
6
- module ErrorHandler
7
- # Handles tool execution crashes gracefully
8
- # Logs error with short traceback, repairs conversation, and returns error message
9
- def handle_tool_crash(chat_instance, exception)
10
- error_msg = "Tool error: #{exception.class} - #{exception.message}"
11
-
12
- # Log error with short traceback (first 5 lines)
13
- warn "\n #{error_msg}"
14
- if exception.backtrace
15
- short_trace = exception.backtrace.first(5).map { |line| " #{line}" }.join("\n")
16
- warn short_trace
17
- end
18
- warn "" # blank line for readability
19
-
20
- # Repair incomplete tool calls to maintain conversation integrity
21
- repair_incomplete_tool_calls(chat_instance, error_msg)
22
-
23
- # Return error message so conversation can continue
24
- error_msg
25
- end
26
-
27
- # Repairs conversation history when a tool call fails (timeout, error, etc.)
28
- # When an MCP tool times out, the conversation gets into an invalid state:
29
- # - Assistant message with tool_calls was added to history
30
- # - But no tool result message was added (because the tool failed)
31
- # - The API requires tool results for each tool_call_id
32
- # This method adds synthetic error tool results to fix the conversation.
33
- def repair_incomplete_tool_calls(chat_instance, error_message)
34
- return unless chat_instance.respond_to?(:messages)
35
-
36
- messages = chat_instance.messages
37
- return if messages.empty?
38
-
39
- # Find the last assistant message that has tool_calls
40
- last_assistant_with_tools = messages.reverse.find do |msg|
41
- msg.role == :assistant && msg.respond_to?(:tool_calls) && msg.tool_calls&.any?
42
- end
43
-
44
- return unless last_assistant_with_tools
45
-
46
- # Get the tool_call_ids that need results
47
- tool_call_ids = last_assistant_with_tools.tool_calls.keys
48
-
49
- # Check which tool_call_ids already have results
50
- existing_tool_results = messages.select { |m| m.role == :tool }.map(&:tool_call_id).compact
51
-
52
- # Add synthetic error results for any missing tool_call_ids
53
- tool_call_ids.each do |tool_call_id|
54
- next if existing_tool_results.include?(tool_call_id.to_s) || existing_tool_results.include?(tool_call_id)
55
-
56
- # Add a synthetic tool result with the error message
57
- chat_instance.add_message(
58
- role: :tool,
59
- content: "Error: #{error_message}",
60
- tool_call_id: tool_call_id
61
- )
62
- end
63
- rescue StandardError
64
- # Don't let repair failures cascade
65
- end
66
- end
67
- end
68
- end
@@ -1,57 +0,0 @@
1
- # lib/aia/adapter/gem_activator.rb
2
- # frozen_string_literal: true
3
-
4
- module AIA
5
- module Adapter
6
- class GemActivator
7
- # Activate a gem and add its lib path to $LOAD_PATH
8
- # This bypasses Bundler's restrictions on loading non-bundled gems
9
- def self.activate_gem_for_require(lib)
10
- return if Gem.try_activate(lib)
11
-
12
- gem_path = find_gem_path(lib)
13
- if gem_path
14
- lib_path = File.join(gem_path, 'lib')
15
- $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
16
- end
17
- end
18
-
19
- # Find gem path by searching gem directories directly
20
- # This bypasses Bundler's restrictions
21
- def self.find_gem_path(gem_name)
22
- gem_dirs = Gem.path.flat_map do |base|
23
- gems_dir = File.join(base, 'gems')
24
- next [] unless File.directory?(gems_dir)
25
-
26
- Dir.glob(File.join(gems_dir, "#{gem_name}-*")).select do |path|
27
- File.directory?(path) && File.basename(path).match?(/^#{Regexp.escape(gem_name)}-[\d.]+/)
28
- end
29
- end
30
-
31
- # Return the most recent version
32
- gem_dirs.sort.last
33
- end
34
-
35
- # Some tool libraries use lazy loading (e.g., Zeitwerk) and need explicit
36
- # triggering to load tool classes into ObjectSpace
37
- def self.trigger_tool_loading(lib)
38
- # Convert lib name to constant (e.g., 'shared_tools' -> SharedTools)
39
- const_name = lib.split(/[_-]/).map(&:capitalize).join
40
-
41
- begin
42
- mod = Object.const_get(const_name)
43
-
44
- # Try common methods that libraries use to load tools
45
- if mod.respond_to?(:load_all_tools)
46
- mod.load_all_tools
47
- elsif mod.respond_to?(:tools)
48
- # Calling .tools often triggers lazy loading
49
- mod.tools
50
- end
51
- rescue NameError
52
- # Constant doesn't exist, library might use different naming
53
- end
54
- end
55
- end
56
- end
57
- end
@@ -1,274 +0,0 @@
1
- # lib/aia/adapter/mcp_connector.rb
2
- # frozen_string_literal: true
3
-
4
- require 'simple_flow'
5
-
6
- module AIA
7
- module Adapter
8
- class McpConnector
9
- # Default timeout for MCP client initialization (in milliseconds)
10
- # RubyLLM::MCP expects timeout in milliseconds (e.g., 8000 = 8 seconds)
11
- # Using a short timeout to prevent slow servers from blocking startup
12
- MCP_DEFAULT_TIMEOUT = 8_000 # 8 seconds (same as RubyLLM::MCP default)
13
-
14
- def support_mcp(tools)
15
- if AIA.config.flags.no_mcp
16
- logger.debug("MCP processing bypassed via --no-mcp flag")
17
- return
18
- end
19
-
20
- if AIA.config.mcp_servers.nil? || AIA.config.mcp_servers.empty?
21
- logger.debug("No MCP servers configured, skipping MCP setup")
22
- return
23
- end
24
-
25
- # Initialize tracking (kept for compatibility with Utility.robot)
26
- AIA.config.connected_mcp_servers = []
27
- AIA.config.failed_mcp_servers = []
28
-
29
- servers = filter_mcp_servers(AIA.config.mcp_servers)
30
- server_names = servers.map { |s| s[:name] || s['name'] }.compact
31
-
32
- logger.info("Starting parallel MCP connection", server_count: servers.size, servers: server_names)
33
- $stderr.puts "MCP: Connecting to #{server_names.join(', ')}..."
34
- $stderr.flush
35
-
36
- LoggerManager.configure_mcp_logger
37
-
38
- # Build steps array first (outside the block to preserve self reference)
39
- # Each step is a [name, callable] pair for parallel execution
40
- connector = self
41
- steps = servers.map do |server|
42
- name = (server[:name] || server['name']).to_sym
43
- logger.debug("Building connection step", server: name)
44
- [name, connector.send(:build_mcp_connection_step, server)]
45
- end
46
-
47
- # Build parallel pipeline - each server is independent (depends_on: :none)
48
- # All servers will connect concurrently using fiber-based async
49
- logger.debug("Creating SimpleFlow pipeline", step_count: steps.size)
50
- pipeline = SimpleFlow::Pipeline.new(concurrency: :async) do
51
- steps.each do |name, callable|
52
- step name, callable, depends_on: :none
53
- end
54
- end
55
-
56
- # Execute all connections in parallel
57
- start_time = Time.now
58
- initial_result = SimpleFlow::Result.new({ tools: [] })
59
- final_result = pipeline.call_parallel(initial_result)
60
- elapsed = Time.now - start_time
61
-
62
- logger.info("Parallel MCP connection completed", elapsed_seconds: elapsed.round(2))
63
-
64
- # Extract results and populate config arrays for compatibility
65
- extract_mcp_results(final_result, tools)
66
- end
67
-
68
- def filter_mcp_servers(servers)
69
- use_list = Array(AIA.config.mcp_use)
70
- skip_list = Array(AIA.config.mcp_skip)
71
-
72
- if !use_list.empty?
73
- servers = servers.select do |server|
74
- name = server[:name] || server['name']
75
- use_list.include?(name)
76
- end
77
- logger.info("MCP servers filtered by --mcp-use", use: use_list, remaining: servers.size)
78
- elsif !skip_list.empty?
79
- servers = servers.reject do |server|
80
- name = server[:name] || server['name']
81
- skip_list.include?(name)
82
- end
83
- logger.info("MCP servers filtered by --mcp-skip", skip: skip_list, remaining: servers.size)
84
- end
85
-
86
- servers
87
- end
88
-
89
- private
90
-
91
- def logger
92
- @logger ||= LoggerManager.aia_logger
93
- end
94
-
95
- def build_mcp_connection_step(server)
96
- # Capture logger in closure for use within the lambda
97
- log = logger
98
-
99
- ->(result) {
100
- name = server[:name] || server['name']
101
- start_time = Time.now
102
-
103
- begin
104
- log.debug("Registering MCP client", server: name)
105
-
106
- # Register client with RubyLLM::MCP
107
- client = register_single_mcp_client(server)
108
-
109
- log.debug("Starting client connection", server: name)
110
-
111
- # Start and verify connection
112
- client.start
113
- caps = client.capabilities
114
- has_capabilities = caps && (caps.is_a?(Hash) ? !caps.empty? : caps)
115
-
116
- elapsed = Time.now - start_time
117
-
118
- if client.alive? && has_capabilities
119
- # Success - get tools and record in context
120
- tools = begin
121
- client.tools
122
- rescue StandardError => tool_err
123
- log.warn("Failed to retrieve tools", server: name, error: tool_err.message)
124
- []
125
- end
126
-
127
- tool_names = tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
128
- log.info("Connected successfully", server: name, elapsed_seconds: elapsed.round(2), tool_count: tools.size)
129
- log.debug("Available tools", server: name, tools: tool_names)
130
-
131
- result
132
- .with_context(name.to_sym, { status: :connected, tools: tools })
133
- .continue(result.value)
134
- else
135
- # Connection issue - determine specific error
136
- error = determine_mcp_connection_error(client, caps)
137
- log.warn("Connection failed", server: name, elapsed_seconds: elapsed.round(2), error: error)
138
- log.debug("Connection details", server: name, alive: client.alive?, capabilities: caps.inspect)
139
-
140
- result
141
- .with_error(name.to_sym, error)
142
- .with_context(name.to_sym, { status: :failed })
143
- .continue(result.value) # Continue to allow other servers
144
- end
145
- rescue StandardError => e
146
- elapsed = Time.now - start_time
147
- error_msg = e.message.downcase.include?('timeout') ?
148
- "Connection timed out" : e.message
149
-
150
- log.error("Connection exception", server: name, elapsed_seconds: elapsed.round(2), error_class: e.class.name, error: error_msg)
151
- log.debug("Exception backtrace", server: name, backtrace: e.backtrace&.first(3))
152
-
153
- result
154
- .with_error(name.to_sym, error_msg)
155
- .with_context(name.to_sym, { status: :failed })
156
- .continue(result.value)
157
- end
158
- }
159
- end
160
-
161
- def register_single_mcp_client(server)
162
- name = server[:name] || server['name']
163
- command = server[:command] || server['command']
164
- args = server[:args] || server['args'] || []
165
- env = server[:env] || server['env'] || {}
166
-
167
- raw_timeout = server[:timeout] || server['timeout'] ||
168
- server[:request_timeout] || server['request_timeout'] ||
169
- MCP_DEFAULT_TIMEOUT
170
- request_timeout = raw_timeout.to_i < 1000 ? (raw_timeout.to_i * 1000) : raw_timeout.to_i
171
- request_timeout = [request_timeout, 30_000].min
172
-
173
- logger.debug("Configuring client", server: name, command: command, args: args, timeout_ms: request_timeout)
174
- logger.debug("Environment variables", server: name, env_keys: env.keys) unless env.empty?
175
-
176
- mcp_config = { command: command, args: Array(args) }
177
- mcp_config[:env] = env unless env.empty?
178
-
179
- begin
180
- logger.debug("Adding client to RubyLLM::MCP with request_timeout", server: name)
181
- RubyLLM::MCP.add_client(
182
- name: name,
183
- transport_type: :stdio,
184
- config: mcp_config,
185
- request_timeout: request_timeout,
186
- start: false
187
- )
188
- rescue ArgumentError => e
189
- # If request_timeout isn't supported in this version, try without it
190
- if e.message.include?('timeout')
191
- logger.debug("Retrying without request_timeout (unsupported in this RubyLLM::MCP version)", server: name)
192
- RubyLLM::MCP.add_client(
193
- name: name,
194
- transport_type: :stdio,
195
- config: mcp_config,
196
- start: false
197
- )
198
- else
199
- logger.error("Failed to add client", server: name, error: e.message)
200
- raise
201
- end
202
- end
203
-
204
- logger.debug("Client registered successfully", server: name)
205
- RubyLLM::MCP.clients[name]
206
- end
207
-
208
- def determine_mcp_connection_error(client, caps)
209
- if !client.alive?
210
- "Connection failed"
211
- elsif caps.nil?
212
- "Connection timed out (no response)"
213
- elsif caps.is_a?(Hash) && caps.empty?
214
- "Connection timed out (empty capabilities)"
215
- else
216
- "Connection timed out (no capabilities received)"
217
- end
218
- end
219
-
220
- def extract_mcp_results(result, tools)
221
- logger.debug("Extracting MCP connection results from SimpleFlow pipeline")
222
- all_tools = []
223
-
224
- result.context.each do |server_name, info|
225
- name = server_name.to_s
226
- if info[:status] == :connected
227
- tool_count = (info[:tools] || []).size
228
- logger.debug("Extracting tools from connected server", server: name, tool_count: tool_count)
229
- AIA.config.connected_mcp_servers << name
230
- all_tools.concat(info[:tools] || [])
231
- end
232
- end
233
-
234
- result.errors.each do |server_name, messages|
235
- logger.debug("Recording failure", server: server_name, error: messages.first)
236
- AIA.config.failed_mcp_servers << {
237
- name: server_name.to_s,
238
- error: messages.first
239
- }
240
- end
241
-
242
- tools.concat(all_tools)
243
-
244
- logger.info("MCP results",
245
- connected_count: AIA.config.connected_mcp_servers.size,
246
- failed_count: AIA.config.failed_mcp_servers.size,
247
- total_tools: all_tools.size
248
- )
249
-
250
- # Report results
251
- report_mcp_connection_results(all_tools.size)
252
- end
253
-
254
- def report_mcp_connection_results(tool_count)
255
- if AIA.config.connected_mcp_servers.any?
256
- logger.info("Successfully connected", servers: AIA.config.connected_mcp_servers)
257
- $stderr.puts "MCP: Connected to #{AIA.config.connected_mcp_servers.join(', ')} (#{tool_count} tools)"
258
- end
259
-
260
- AIA.config.failed_mcp_servers.each do |failure|
261
- logger.warn("Server failed", server: failure[:name], error: failure[:error])
262
- $stderr.puts " MCP: '#{failure[:name]}' failed - #{failure[:error]}"
263
- end
264
-
265
- if AIA.config.connected_mcp_servers.empty? && AIA.config.failed_mcp_servers.any?
266
- logger.error("No MCP servers connected successfully")
267
- $stderr.puts "MCP: No servers connected successfully"
268
- end
269
-
270
- $stderr.flush
271
- end
272
- end
273
- end
274
- end