aia 2.0.0.0.pre.alpha → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +3 -223
  4. data/.reek.yml +160 -0
  5. data/.rubocop_strict.yml +15 -0
  6. data/.version +1 -1
  7. data/CHANGELOG.md +10 -5
  8. data/README.md +1 -1
  9. data/Rakefile +0 -113
  10. data/_typos.toml +10 -0
  11. data/aia.gemspec +92 -0
  12. data/config/aia.yml +13 -0
  13. data/lib/aia/chat_loop.rb +8 -52
  14. data/lib/aia/config/cli_parser.rb +66 -57
  15. data/lib/aia/config/mcp_parser.rb +27 -19
  16. data/lib/aia/config/validator.rb +83 -94
  17. data/lib/aia/config.rb +3 -2
  18. data/lib/aia/content_extractor.rb +2 -0
  19. data/lib/aia/cost_calculator.rb +1 -0
  20. data/lib/aia/debate_handler.rb +28 -20
  21. data/lib/aia/delegate_handler.rb +7 -3
  22. data/lib/aia/directive.rb +10 -8
  23. data/lib/aia/directives/configuration_directives.rb +115 -101
  24. data/lib/aia/directives/context_directives.rb +31 -28
  25. data/lib/aia/directives/execution_directives.rb +18 -13
  26. data/lib/aia/directives/model_directives.rb +134 -154
  27. data/lib/aia/directives/trakflow_directives.rb +27 -9
  28. data/lib/aia/directives/utility_directives.rb +71 -71
  29. data/lib/aia/directives/web_and_file_directives.rb +63 -50
  30. data/lib/aia/layered_orchestrator.rb +66 -43
  31. data/lib/aia/logger.rb +21 -21
  32. data/lib/aia/mcp_connection_manager.rb +50 -40
  33. data/lib/aia/mcp_server_config.rb +30 -0
  34. data/lib/aia/mcp_utility.rb +7 -4
  35. data/lib/aia/mention_router.rb +7 -50
  36. data/lib/aia/network_builder.rb +10 -5
  37. data/lib/aia/network_memory_manager.rb +6 -6
  38. data/lib/aia/pipeline_orchestrator.rb +20 -10
  39. data/lib/aia/plugin_monitor.rb +9 -6
  40. data/lib/aia/prompt_decomposer.rb +5 -3
  41. data/lib/aia/prompt_handler.rb +43 -49
  42. data/lib/aia/robot_factory.rb +12 -8
  43. data/lib/aia/robot_namer.rb +2 -8
  44. data/lib/aia/session.rb +7 -3
  45. data/lib/aia/session_tracker.rb +39 -41
  46. data/lib/aia/similarity_scorer.rb +5 -3
  47. data/lib/aia/special_mode_handler.rb +52 -32
  48. data/lib/aia/speech.rb +67 -0
  49. data/lib/aia/startup_coordinator.rb +1 -0
  50. data/lib/aia/streaming_runner.rb +6 -3
  51. data/lib/aia/system_prompt_assembler.rb +11 -7
  52. data/lib/aia/task_coordinator.rb +10 -5
  53. data/lib/aia/timing.rb +15 -0
  54. data/lib/aia/tool_filter.rb +1 -0
  55. data/lib/aia/tool_filter_strategy.rb +28 -25
  56. data/lib/aia/tool_introspection.rb +17 -0
  57. data/lib/aia/tool_loader.rb +12 -6
  58. data/lib/aia/tools/task_board_tool.rb +1 -0
  59. data/lib/aia/trakflow_bridge.rb +5 -3
  60. data/lib/aia/turn_state.rb +1 -0
  61. data/lib/aia/ui_presenter.rb +67 -59
  62. data/lib/aia/utility.rb +5 -3
  63. data/lib/aia/variable_input_collector.rb +1 -0
  64. data/lib/aia/verification_network.rb +1 -2
  65. data/lib/aia.rb +16 -0
  66. metadata +15 -9
  67. data/.quality/flay_baseline.txt +0 -1
  68. data/.quality/flog_baseline.txt +0 -29
  69. data/.quality/reek_baseline.txt +0 -80
@@ -10,29 +10,34 @@ module AIA
10
10
  PUREMD_API_KEY = ENV.fetch('PUREMD_API_KEY', nil)
11
11
 
12
12
  desc "Fetch and include content from a webpage"
13
+ # :reek:TooManyStatements -- guard, fetch via pure.md, then success/error reporting in one flow
13
14
  def webpage(args, _context_manager = nil)
14
15
  if PUREMD_API_KEY.nil?
15
- 'ERROR: PUREMD_API_KEY is required in order to include a webpage'
16
- else
17
- url = args.shift.to_s.strip
18
- puremd_url = "https://pure.md/#{url}"
16
+ msg = 'ERROR: PUREMD_API_KEY is required in order to include a webpage'
17
+ AIA::LoggerManager.aia_logger.error(msg)
18
+ puts msg
19
+ return nil
20
+ end
19
21
 
20
- response = Faraday.get(puremd_url) do |req|
21
- req.headers['x-puremd-api-token'] = PUREMD_API_KEY
22
- end
22
+ url = args.shift.to_s.strip
23
+ puremd_url = "https://pure.md/#{url}"
23
24
 
24
- if response.status == 200
25
- response.body
26
- else
27
- "Error: Status was #{response.status}\n#{ap response}"
28
- end
25
+ response = Faraday.get(puremd_url) do |req|
26
+ req.headers['x-puremd-api-token'] = PUREMD_API_KEY
29
27
  end
28
+
29
+ return response.body if response.status == 200
30
+
31
+ msg = "Error: Fetching #{url} returned status #{response.status}"
32
+ AIA::LoggerManager.aia_logger.error(msg)
33
+ puts msg
34
+ nil
30
35
  end
31
36
  alias website webpage
32
37
  alias web webpage
33
38
 
34
39
  desc "List available AIA skills"
35
- # rubocop:disable Metrics/AbcSize
40
+ # :reek:TooManyStatements -- sequential listing: directory guard, term filtering, per-skill output
36
41
  def skills(args = [], _context_manager = nil)
37
42
  dir = aia_skills_dir
38
43
  unless Dir.exist?(dir)
@@ -41,18 +46,7 @@ module AIA
41
46
  end
42
47
 
43
48
  positive_terms, negative_terms = parse_search_terms(Array(args))
44
-
45
- skill_dirs = Dir.children(dir).select do |e|
46
- File.directory?(File.join(dir, e)) &&
47
- File.exist?(File.join(dir, e, 'SKILL.md'))
48
- end
49
- entries = skill_dirs.select do |e|
50
- next true if positive_terms.empty? && negative_terms.empty?
51
- text = read_front_matter_text(File.join(dir, e, 'SKILL.md'))
52
- # text is a String; Array#intersect? would raise TypeError
53
- positive_terms.all? { |t| text.include?(t) } &&
54
- negative_terms.none? { |t| text.include?(t) }
55
- end.sort
49
+ entries = matching_skill_ids(dir, positive_terms, negative_terms)
56
50
 
57
51
  if entries.empty?
58
52
  all_terms = positive_terms + negative_terms
@@ -61,38 +55,23 @@ module AIA
61
55
  end
62
56
 
63
57
  wrap_width = terminal_width - 2
64
-
65
- entries.each do |skill_id|
66
- fm = parse_skill_front_matter(File.join(dir, skill_id, 'SKILL.md'))
67
- name = fm['name'] || ''
68
- description = fm['description'] || '(no description)'
69
- puts "#{skill_id}: #{name}"
70
- puts word_wrap(description, width: wrap_width, indent: ' ')
71
- puts
72
- end
58
+ entries.each { |skill_id| print_skill_entry(dir, skill_id, wrap_width) }
73
59
 
74
60
  nil
75
61
  end
76
- # rubocop:enable Metrics/AbcSize
77
62
 
78
63
  desc "Include an AIA skill from the configured skills directory"
79
64
  def skill(args = [], _context_manager = nil)
80
65
  args = Array(args)
81
66
  skill_name = args.first&.strip
82
67
  if skill_name.nil? || skill_name.empty?
83
- msg = "Error: /skill requires a skill name. Use /skills to list available skills."
84
- AIA::LoggerManager.aia_logger.error(msg)
85
- puts msg
86
- return nil
68
+ return report_skill_error("Error: /skill requires a skill name. Use /skills to list available skills.")
87
69
  end
88
70
 
89
71
  dir = aia_skills_dir
90
72
  skill_dir = find_skill_dir(skill_name, dir)
91
73
  unless skill_dir
92
- msg = "Error: No skill matching '#{skill_name}' found in #{dir}. Use /skills to list available skills."
93
- AIA::LoggerManager.aia_logger.error(msg)
94
- puts msg
95
- return nil
74
+ return report_skill_error("Error: No skill matching '#{skill_name}' found in #{dir}. Use /skills to list available skills.")
96
75
  end
97
76
 
98
77
  return File.read(skill_dir) if File.file?(skill_dir)
@@ -104,9 +83,7 @@ module AIA
104
83
  else
105
84
  "Error: Skill '#{File.basename(skill_dir)}' has no SKILL.md in #{dir}."
106
85
  end
107
- AIA::LoggerManager.aia_logger.error(msg)
108
- puts msg
109
- return nil
86
+ return report_skill_error(msg)
110
87
  end
111
88
 
112
89
  File.read(skill_path)
@@ -117,17 +94,53 @@ module AIA
117
94
  content = Clipboard.paste
118
95
  content.to_s
119
96
  rescue StandardError => e
120
- "Error: Unable to paste from clipboard - #{e.message}"
97
+ msg = "Error: Unable to paste from clipboard - #{e.message}"
98
+ AIA::LoggerManager.aia_logger.error(msg)
99
+ puts msg
100
+ nil
121
101
  end
122
102
  alias clipboard paste
123
103
 
124
104
  private
125
105
 
106
+ # Skill directories under dir with a SKILL.md whose front matter passes
107
+ # the positive/negative term filters, sorted.
108
+ def matching_skill_ids(dir, positive_terms, negative_terms)
109
+ skill_dirs = Dir.children(dir).select do |e|
110
+ File.directory?(File.join(dir, e)) &&
111
+ File.exist?(File.join(dir, e, 'SKILL.md'))
112
+ end
113
+
114
+ skill_dirs.select do |e|
115
+ next true if positive_terms.empty? && negative_terms.empty?
116
+ text = read_front_matter_text(File.join(dir, e, 'SKILL.md'))
117
+ # text is a String; Array#intersect? would raise TypeError
118
+ positive_terms.all? { |t| text.include?(t) } &&
119
+ negative_terms.none? { |t| text.include?(t) }
120
+ end.sort
121
+ end
122
+
123
+ def print_skill_entry(dir, skill_id, wrap_width)
124
+ fm = parse_skill_front_matter(File.join(dir, skill_id, 'SKILL.md'))
125
+ name = fm['name'] || ''
126
+ description = fm['description'] || '(no description)'
127
+ puts "#{skill_id}: #{name}"
128
+ puts word_wrap(description, width: wrap_width, indent: ' ')
129
+ puts
130
+ end
131
+
132
+ # Log and print a /skill error message; returns nil so callers can
133
+ # `return report_skill_error(msg)` from any guard.
134
+ def report_skill_error(msg)
135
+ AIA::LoggerManager.aia_logger.error(msg)
136
+ puts msg
137
+ nil
138
+ end
139
+
126
140
  # Resolve the AIA skills directory from config, env vars, or defaults.
127
141
  def aia_skills_dir
128
- if AIA.respond_to?(:config) && AIA.config&.skills.respond_to?(:dir) && AIA.config.skills.dir
129
- return AIA.config.skills.dir
130
- end
142
+ skills = AIA.respond_to?(:config) ? AIA.config&.skills : nil
143
+ return skills.dir if skills.respond_to?(:dir) && skills.dir
131
144
 
132
145
  ENV.fetch('AIA_SKILLS__DIR', File.expand_path('~/.prompts/skills'))
133
146
  end
@@ -34,6 +34,12 @@ module AIA
34
34
  MAX_LAYERS = 5
35
35
  MAX_TASKS_PER_LAYER = 4
36
36
 
37
+ BUILD_BANNER = <<~BANNER
38
+ ╔══════════════════════════════════════════════════════════╗
39
+ ║ LAYERED ORCHESTRATION — 3-TIER BUILD ║
40
+ ╚══════════════════════════════════════════════════════════╝
41
+ BANNER
42
+
37
43
  # Tier 1 prompt: decompose requirements into layers
38
44
  # Deliberately short and directive to minimise qwen3 think-block noise.
39
45
  LAYER_DECOMPOSE_PROMPT = <<~PROMPT
@@ -124,19 +130,14 @@ module AIA
124
130
  #
125
131
  # @param context [HandlerContext] — reads context.prompt as requirements text
126
132
  # @return [String, nil] final synthesis or nil on failure
127
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
133
+ # :reek:TooManyStatements -- single orchestration script (banner, tier 1-3 waves, synthesis, report); the narrative order is the value
134
+ # :reek:DuplicateMethodCall -- say("") prints deliberate blank separator lines between build phases; not a hoistable value
128
135
  def handle(context)
129
136
  requirements = context.prompt
130
137
  primary = @robot.chief
131
138
 
132
139
  FileUtils.mkdir_p(@build_dir)
133
- say("")
134
- say("╔══════════════════════════════════════════════════════════╗")
135
- say("║ LAYERED ORCHESTRATION — 3-TIER BUILD ║")
136
- say("╚══════════════════════════════════════════════════════════╝")
137
- say("Requirements: #{requirements.lines.first.strip}")
138
- say("Build output: #{@build_dir}")
139
- say("")
140
+ print_build_banner(requirements)
140
141
 
141
142
  # Tier 1: Tobor decomposes requirements into layers
142
143
  layers = decompose_to_layers(primary, requirements)
@@ -173,23 +174,38 @@ module AIA
173
174
  save_final_report(final_text)
174
175
  say("Build complete: #{@build_dir}")
175
176
 
177
+ record_final_turn(requirements, final_text)
178
+ final_text
179
+ rescue StandardError => e
180
+ report_orchestration_error(e)
181
+ nil
182
+ end
183
+
184
+ private
185
+
186
+ def print_build_banner(requirements)
187
+ say("")
188
+ BUILD_BANNER.each_line { |line| say(line.chomp) }
189
+ say("Requirements: #{requirements.lines.first.strip}")
190
+ say("Build output: #{@build_dir}")
191
+ say("")
192
+ end
193
+
194
+ def record_final_turn(requirements, final_text)
176
195
  @tracker.record_turn(
177
196
  model: AIA.config.models.first.name,
178
197
  input: requirements,
179
198
  result: final_text
180
199
  )
181
-
182
- final_text
183
- rescue StandardError => e
184
- say("✗ Orchestration error: #{e.class}: #{e.message}")
185
- e.backtrace&.first(5)&.each { |line| say(" #{line}") }
186
- nil
187
200
  end
188
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
189
201
 
190
- private
202
+ def report_orchestration_error(error)
203
+ say("✗ Orchestration error: #{error.class}: #{error.message}")
204
+ error.backtrace&.first(5)&.each { |line| say(" #{line}") }
205
+ end
191
206
 
192
207
  # Tier 1: use a probe robot to decompose requirements into layer specs
208
+ # :reek:TooManyStatements -- probe run plus parse-failure diagnostics and rescue reporting
193
209
  def decompose_to_layers(robot, requirements)
194
210
  say("Tier 1 ▶ #{robot.name} decomposing requirements into layers...")
195
211
  probe = build_probe(robot.name + "-layer-probe")
@@ -227,6 +243,7 @@ module AIA
227
243
  #
228
244
  # @param layers [Array<Hash>] layer specs from Tier 1
229
245
  # @return [Hash{ String => Array<Hash> }] layer_name => task list ([] on failure)
246
+ # :reek:TooManyStatements -- per-layer async fan-out with per-layer error capture; the barrier plumbing is inherent
230
247
  def run_leads_wave(layers)
231
248
  results = {}
232
249
 
@@ -234,19 +251,21 @@ module AIA
234
251
  barrier = Async::Barrier.new
235
252
  layers.each do |layer|
236
253
  barrier.async do
237
- probe = build_probe("#{layer['name']}-lead")
254
+ name = layer['name']
255
+ title = layer['title']
256
+ probe = build_probe("#{name}-lead")
238
257
  prompt = TASK_DECOMPOSE_PROMPT % {
239
- layer_title: layer['title'],
258
+ layer_title: title,
240
259
  requirements: layer['requirements'].to_s,
241
260
  max_tasks: MAX_TASKS_PER_LAYER
242
261
  }
243
262
  result = probe.run(prompt, mcp: :none, tools: :none)
244
263
  tasks = parse_json_array(extract_content(result)).first(MAX_TASKS_PER_LAYER)
245
- say(" ✓ #{layer['title']} lead: #{tasks.size} task(s)")
246
- results[layer['name']] = tasks
264
+ say(" ✓ #{title} lead: #{tasks.size} task(s)")
265
+ results[name] = tasks
247
266
  rescue => e
248
- say(" ✗ #{layer['title']} lead failed: #{e.message}")
249
- results[layer['name']] = []
267
+ say(" ✗ #{title} lead failed: #{e.message}")
268
+ results[name] = []
250
269
  end
251
270
  end
252
271
  barrier.wait
@@ -261,6 +280,7 @@ module AIA
261
280
  #
262
281
  # @param layer_task_map [Hash{ String => Array<Hash> }] output of run_leads_wave
263
282
  # @return [Hash{ String => Hash }] "layer_name|artifact_path" => result hash
283
+ # :reek:TooManyStatements -- per-task async fan-out with success/failure result recording; the barrier plumbing is inherent
264
284
  def run_specialists_wave(layer_task_map)
265
285
  jobs = layer_task_map.flat_map do |layer_name, tasks|
266
286
  tasks.map { |task| { layer_name: layer_name, task: task } }
@@ -273,31 +293,32 @@ module AIA
273
293
  barrier = Async::Barrier.new
274
294
  jobs.each do |job|
275
295
  barrier.async do
276
- task = job[:task]
277
- key = "#{job[:layer_name]}|#{task['artifact']}"
278
- probe = build_probe("#{task['specialist']}-specialist")
279
-
280
- full_prompt = "Artifact to produce: #{task['artifact']}\n\n#{task['prompt']}"
296
+ task = job[:task]
297
+ artifact = task['artifact']
298
+ title = task['title']
299
+ specialist = task['specialist']
300
+ key = "#{job[:layer_name]}|#{artifact}"
301
+ probe = build_probe("#{specialist}-specialist")
302
+
303
+ full_prompt = "Artifact to produce: #{artifact}\n\n#{task['prompt']}"
281
304
  result = probe.run(full_prompt, mcp: :none, tools: :none)
282
305
  output = extract_content(result)
283
306
 
284
- save_artifact(task['artifact'], output)
285
- say(" ✓ #{task['artifact']}")
307
+ save_artifact(artifact, output)
308
+ say(" ✓ #{artifact}")
286
309
 
287
310
  results[key] = {
288
- task: task['title'],
289
- specialist: task['specialist'],
290
- artifact: task['artifact'],
311
+ task: title,
312
+ specialist: specialist,
313
+ artifact: artifact,
291
314
  output: output
292
315
  }
293
316
  rescue => e
294
- task = job[:task]
295
- key = "#{job[:layer_name]}|#{task['artifact']}"
296
- say(" ✗ #{task['artifact']} failed: #{e.message}")
317
+ say(" ✗ #{artifact} failed: #{e.message}")
297
318
  results[key] = {
298
- task: task['title'],
299
- specialist: task['specialist'],
300
- artifact: task['artifact'],
319
+ task: title,
320
+ specialist: specialist,
321
+ artifact: artifact,
301
322
  output: "[FAILED: #{e.message}]"
302
323
  }
303
324
  end
@@ -317,9 +338,11 @@ module AIA
317
338
  # @return [Array<Hash>]
318
339
  def build_layer_results(layers, layer_task_map, specialist_results)
319
340
  layers.map do |layer|
320
- tasks_for_layer = layer_task_map[layer['name']] || []
341
+ name = layer['name']
342
+ title = layer['title']
343
+ tasks_for_layer = layer_task_map[name] || []
321
344
  task_results = tasks_for_layer.map do |task|
322
- key = "#{layer['name']}|#{task['artifact']}"
345
+ key = "#{name}|#{task['artifact']}"
323
346
  specialist_results[key] || {
324
347
  task: task['title'],
325
348
  specialist: task['specialist'],
@@ -328,9 +351,9 @@ module AIA
328
351
  }
329
352
  end
330
353
 
331
- summary = synthesize_layer_from_results(layer['title'], task_results)
332
- say(" ✓ #{layer['title']} synthesis complete")
333
- { layer: layer['title'], tasks: task_results, summary: summary }
354
+ summary = synthesize_layer_from_results(title, task_results)
355
+ say(" ✓ #{title} synthesis complete")
356
+ { layer: title, tasks: task_results, summary: summary }
334
357
  end
335
358
  end
336
359
 
data/lib/aia/logger.rb CHANGED
@@ -76,22 +76,23 @@ module AIA
76
76
  def configure_llm_logger
77
77
  return unless defined?(RubyLLM)
78
78
 
79
- logger = llm_logger
79
+ logger = llm_logger
80
+ llm_config = RubyLLM.config
80
81
 
81
82
  # Set our logger on the RubyLLM config object
82
- if RubyLLM.config.respond_to?(:logger=)
83
- RubyLLM.config.logger = logger
83
+ if llm_config.respond_to?(:logger=)
84
+ llm_config.logger = logger
84
85
  end
85
86
 
86
87
  # Also set log_file and log_level in case the logger gets recreated
87
- if RubyLLM.config.respond_to?(:log_file=)
88
+ if llm_config.respond_to?(:log_file=)
88
89
  file = effective_log_file(logger_config_for(:llm))
89
- RubyLLM.config.log_file = resolve_log_file_io(file)
90
+ llm_config.log_file = resolve_log_file_io(file)
90
91
  end
91
92
 
92
- if RubyLLM.config.respond_to?(:log_level=)
93
+ if llm_config.respond_to?(:log_level=)
93
94
  level = effective_log_level(logger_config_for(:llm))
94
- RubyLLM.config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
95
+ llm_config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
95
96
  end
96
97
 
97
98
  # Reset the memoized @logger on RubyLLM module so next call uses our config
@@ -307,23 +308,21 @@ module AIA
307
308
  # @param file [String] The file config value
308
309
  # @param flush [Boolean] If true, flush immediately (no buffering)
309
310
  # @return [Lumberjack::Device] The device instance
311
+ # :reek:BooleanParameter -- flush: maps directly onto Lumberjack's autoflush; two constructors would obscure that single toggle
310
312
  def create_device(file, flush: true)
311
- # buffer_size: 0 means immediate flush (no buffering)
312
- buffer_size = flush ? 0 : 8192
313
-
314
313
  case file.to_s.upcase
315
314
  when 'STDOUT'
316
- Lumberjack::Device::Writer.new($stdout, buffer_size: buffer_size)
315
+ Lumberjack::Device::Writer.new($stdout, autoflush: flush)
317
316
  when 'STDERR'
318
- Lumberjack::Device::Writer.new($stderr, buffer_size: buffer_size)
317
+ Lumberjack::Device::Writer.new($stderr, autoflush: flush)
319
318
  else
320
319
  path = File.expand_path(file)
321
- # Use date rolling for file-based logs
322
- # Multiple loggers can safely write to the same file
323
- Lumberjack::Device::DateRollingLogFile.new(
320
+ # Daily date rolling via Logger::LogDevice, which also makes the
321
+ # file safe for multiple loggers to write to
322
+ Lumberjack::Device::LogFile.new(
324
323
  path,
325
- roll: :daily,
326
- buffer_size: buffer_size
324
+ shift_age: 'daily',
325
+ autoflush: flush
327
326
  )
328
327
  end
329
328
  end
@@ -333,12 +332,13 @@ module AIA
333
332
  # @param system [Symbol] The system (:aia, :llm, :mcp)
334
333
  # @return [ConfigSection, nil] The configuration section
335
334
  def logger_config_for(system)
336
- return nil unless AIA.config&.logger
335
+ logger_cfg = AIA.config&.logger
336
+ return nil unless logger_cfg
337
337
 
338
338
  case system
339
- when :aia then AIA.config.logger.aia
340
- when :llm then AIA.config.logger.llm
341
- when :mcp then AIA.config.logger.mcp
339
+ when :aia then logger_cfg.aia
340
+ when :llm then logger_cfg.llm
341
+ when :mcp then logger_cfg.mcp
342
342
  end
343
343
  rescue NoMethodError
344
344
  nil
@@ -10,6 +10,7 @@ require 'timeout'
10
10
  require 'tty-spinner'
11
11
 
12
12
  module AIA
13
+ # :reek:TooManyMethods -- one small helper per connection-lifecycle concern (connect, wrap tools, record outcomes)
13
14
  class MCPConnectionManager
14
15
  # Maximum seconds to wait for a single MCP server to connect.
15
16
  DEFAULT_TIMEOUT = 30
@@ -30,6 +31,7 @@ module AIA
30
31
  #
31
32
  # @param servers [Array<Hash>] MCP server configurations
32
33
  # @return [self]
34
+ # :reek:TooManyStatements -- connection ceremony: spinner setup, thread launch, global timeout guard, summary logging
33
35
  def connect_all(servers)
34
36
  return self unless servers.is_a?(Array) && servers.any?
35
37
 
@@ -76,9 +78,10 @@ module AIA
76
78
  connected_keys, tool_counts, failed = @mutex.synchronize do
77
79
  [@connected_clients.keys.dup, @server_tool_counts.dup, @failed_servers.dup]
78
80
  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
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
82
85
  self
83
86
  end
84
87
 
@@ -98,6 +101,7 @@ module AIA
98
101
  # the connected state so inject_into will include them in the robot.
99
102
  #
100
103
  # @return [self]
104
+ # :reek:TooManyStatements -- per-client absorb loop: start if needed, extract tools, merge into connected state with error capture
101
105
  def absorb_ruby_llm_mcp_clients
102
106
  return self unless defined?(RubyLLM::MCP)
103
107
 
@@ -189,14 +193,17 @@ module AIA
189
193
  logger = AIA::LoggerManager.mcp_logger
190
194
  logger.warn("MCP: Global connection timeout (#{global_limit}s) reached")
191
195
 
192
- connected_names = @mutex.synchronize { @connected_clients.keys.to_set }
193
- failed_names = @mutex.synchronize { @failed_servers.to_set { |f| f[:name] } }
196
+ connected_names, failed_names = @mutex.synchronize do
197
+ [@connected_clients.keys.to_set, @failed_servers.to_set { |f| f[:name] }]
198
+ end
194
199
 
195
- servers.each do |server_config|
200
+ stragglers = servers.filter_map do |server_config|
196
201
  srv_name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
197
202
  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)" } }
203
+
204
+ { name: srv_name, error: "global connection timeout (#{global_limit}s)" }
199
205
  end
206
+ @mutex.synchronize { @failed_servers.concat(stragglers) }
200
207
  end
201
208
 
202
209
  # Read global MCP connection timeout from config.
@@ -208,61 +215,64 @@ module AIA
208
215
  end
209
216
 
210
217
  # Connect a single MCP server, updating the spinner on completion.
211
- # rubocop:disable Metrics/MethodLength
218
+ # :reek:TooManyStatements -- one connect attempt with per-outcome reporting (success, refused, timeout, error)
212
219
  def connect_one(server_config, name, spinner, logger)
213
220
  timeout = server_timeout(server_config)
214
221
  spinner.auto_spin
215
222
 
216
223
  logger.info("MCP: connecting to '#{name}'...")
217
- # rubocop:disable Metrics/BlockLength
218
224
  Timeout.timeout(timeout) do
219
225
  client = RobotLab::MCP::Client.new(server_config)
220
226
  client.connect
221
227
 
222
228
  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)")
229
+ record_connected(name, client, build_mcp_tools(client, name), spinner, logger)
243
230
  else
244
- @mutex.synchronize do
245
- @failed_servers << { name: name, error: "connection failed" }
246
- end
231
+ record_failed_server(name, "connection failed")
247
232
  logger.warn("MCP: '#{name}' failed to connect")
248
233
  spinner.error("(connection failed)")
249
234
  end
250
235
  end
251
- # rubocop:enable Metrics/BlockLength
252
236
  rescue Timeout::Error
253
- @mutex.synchronize do
254
- @failed_servers << { name: name, error: "timed out after #{timeout}s" }
255
- end
237
+ record_failed_server(name, "timed out after #{timeout}s")
256
238
  logger.warn("MCP: '#{name}' timed out after #{timeout}s")
257
239
  spinner.error("(timed out)")
258
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
+
259
263
  @mutex.synchronize do
260
- @failed_servers << { name: name, error: e.message }
264
+ @connected_clients[name] = client
265
+ @server_tool_counts[name] = tool_count
266
+ add_tools_deduped(tools, name, logger)
261
267
  end
262
- logger.warn("MCP: '#{name}' error: #{e.message}")
263
- spinner.error("(#{e.message})")
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 } }
264
275
  end
265
- # rubocop:enable Metrics/MethodLength
266
276
 
267
277
  # Add tools to @connected_tools, skipping any whose name is already present.
268
278
  # Logs a warning for each duplicate so the user knows which server wins.
@@ -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
@@ -26,14 +26,17 @@ module AIA
26
26
  AIA.config&.failed_mcp_servers || []
27
27
  end
28
28
 
29
+ # :reek:TooManyStatements -- server-name resolution applying no_mcp, use-list, and skip-list filters in precedence order
29
30
  def effective_mcp_server_names
30
- return [] if AIA.config&.flags&.no_mcp
31
- servers = AIA.config&.mcp_servers || []
31
+ cfg = AIA.config
32
+ return [] if cfg&.flags&.no_mcp
33
+
34
+ servers = cfg&.mcp_servers || []
32
35
  return [] if servers.empty?
33
36
 
34
37
  names = servers.map { |s| server_name(s) }.compact
35
- use_list = Array(AIA.config.mcp_use)
36
- skip_list = Array(AIA.config.mcp_skip)
38
+ use_list = Array(cfg.mcp_use)
39
+ skip_list = Array(cfg.mcp_skip)
37
40
 
38
41
  if use_list.any?
39
42
  names.select { |n| use_list.include?(n) }