aia 0.9.24 → 0.10.2

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.version +1 -1
  3. data/CHANGELOG.md +84 -3
  4. data/README.md +179 -59
  5. data/bin/aia +6 -0
  6. data/docs/cli-reference.md +145 -72
  7. data/docs/configuration.md +156 -19
  8. data/docs/examples/tools/index.md +2 -2
  9. data/docs/faq.md +11 -11
  10. data/docs/guides/available-models.md +11 -11
  11. data/docs/guides/basic-usage.md +18 -17
  12. data/docs/guides/chat.md +57 -11
  13. data/docs/guides/executable-prompts.md +15 -15
  14. data/docs/guides/first-prompt.md +2 -2
  15. data/docs/guides/getting-started.md +6 -6
  16. data/docs/guides/image-generation.md +24 -24
  17. data/docs/guides/local-models.md +2 -2
  18. data/docs/guides/models.md +96 -18
  19. data/docs/guides/tools.md +4 -4
  20. data/docs/installation.md +2 -2
  21. data/docs/prompt_management.md +11 -11
  22. data/docs/security.md +3 -3
  23. data/docs/workflows-and-pipelines.md +1 -1
  24. data/examples/README.md +6 -6
  25. data/examples/headlines +3 -3
  26. data/lib/aia/aia_completion.bash +2 -2
  27. data/lib/aia/aia_completion.fish +4 -4
  28. data/lib/aia/aia_completion.zsh +2 -2
  29. data/lib/aia/chat_processor_service.rb +31 -21
  30. data/lib/aia/config/cli_parser.rb +403 -403
  31. data/lib/aia/config/config_section.rb +87 -0
  32. data/lib/aia/config/defaults.yml +219 -0
  33. data/lib/aia/config/defaults_loader.rb +147 -0
  34. data/lib/aia/config/mcp_parser.rb +151 -0
  35. data/lib/aia/config/model_spec.rb +67 -0
  36. data/lib/aia/config/validator.rb +185 -136
  37. data/lib/aia/config.rb +336 -17
  38. data/lib/aia/directive_processor.rb +14 -6
  39. data/lib/aia/directives/configuration.rb +24 -10
  40. data/lib/aia/directives/models.rb +3 -4
  41. data/lib/aia/directives/utility.rb +3 -2
  42. data/lib/aia/directives/web_and_file.rb +50 -47
  43. data/lib/aia/logger.rb +328 -0
  44. data/lib/aia/prompt_handler.rb +18 -22
  45. data/lib/aia/ruby_llm_adapter.rb +572 -69
  46. data/lib/aia/session.rb +9 -8
  47. data/lib/aia/ui_presenter.rb +20 -16
  48. data/lib/aia/utility.rb +50 -18
  49. data/lib/aia.rb +91 -66
  50. data/lib/extensions/ruby_llm/modalities.rb +2 -0
  51. data/mcp_servers/apple-mcp.json +8 -0
  52. data/mcp_servers/mcp_server_chart.json +11 -0
  53. data/mcp_servers/playwright_one.json +8 -0
  54. data/mcp_servers/playwright_two.json +8 -0
  55. data/mcp_servers/tavily_mcp_server.json +8 -0
  56. metadata +83 -25
  57. data/lib/aia/config/base.rb +0 -308
  58. data/lib/aia/config/defaults.rb +0 -91
  59. data/lib/aia/config/file_loader.rb +0 -163
  60. data/mcp_servers/imcp.json +0 -7
  61. data/mcp_servers/launcher.json +0 -11
  62. data/mcp_servers/timeserver.json +0 -8
@@ -1,6 +1,9 @@
1
1
  # lib/aia/ruby_llm_adapter.rb
2
2
 
3
3
  require 'async'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'simple_flow'
4
7
  require_relative '../extensions/ruby_llm/provider_fix'
5
8
 
6
9
  module AIA
@@ -21,13 +24,15 @@ module AIA
21
24
 
22
25
  def configure_rubyllm
23
26
  # TODO: Add some of these configuration items to AIA.config
27
+ # Note: RubyLLM supports specific providers. Use provider prefix (e.g., "xai/grok-beta")
28
+ # for providers not directly configured here.
24
29
  RubyLLM.configure do |config|
25
30
  config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
26
31
  config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil)
27
32
  config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil)
28
33
  config.gpustack_api_key = ENV.fetch('GPUSTACK_API_KEY', nil)
29
34
  config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', nil)
30
- config.openrouter_api_key = ENV.fetch('OPENROUTER_API_KEY', nil)
35
+ config.openrouter_api_key = ENV.fetch('OPEN_ROUTER_API_KEY', nil)
31
36
  config.perplexity_api_key = ENV.fetch('PERPLEXITY_API_KEY', nil)
32
37
 
33
38
  # These providers require a little something extra
@@ -65,20 +70,79 @@ module AIA
65
70
  # Connection pooling settings removed - not supported in current RubyLLM version
66
71
  # config.connection_pool_size = 10 # Number of connections to maintain in pool
67
72
  # config.connection_pool_timeout = 60 # Connection pool timeout in seconds
68
- # config.log_file = '/logs/ruby_llm.log'
69
- config.log_level = :fatal # debug level can also be set to debug by setting RUBYLLM_DEBUG envar to true
73
+
74
+ # Configure RubyLLM logger from centralized LoggerManager
75
+ config.log_level = LoggerManager.llm_log_level_symbol
70
76
  end
77
+
78
+ # Configure RubyLLM's logger output destination
79
+ LoggerManager.configure_llm_logger
80
+
81
+ # Configure RubyLLM::MCP's logger early, before any MCP operations
82
+ # This ensures all MCP debug/info logs go to the configured log file
83
+ LoggerManager.configure_mcp_logger
71
84
  end
72
85
 
73
86
 
74
87
  def refresh_local_model_registry
75
- if AIA.config.refresh.nil? ||
76
- Integer(AIA.config.refresh).zero? ||
77
- Date.today > (AIA.config.last_refresh + Integer(AIA.config.refresh))
78
- RubyLLM.models.refresh!
79
- AIA.config.last_refresh = Date.today
80
- AIA::Config.dump_config(AIA.config, AIA.config.config_file) if AIA.config.config_file
88
+ return if models_json_path.nil? # Skip if no aia_dir configured
89
+
90
+ # Coerce refresh_days to integer (env vars come as strings)
91
+ refresh_days = AIA.config.registry.refresh
92
+ refresh_days = refresh_days.to_i if refresh_days.respond_to?(:to_i)
93
+ refresh_days ||= 7 # Default to 7 days if nil
94
+
95
+ last_refresh = models_last_refresh
96
+ models_exist = !last_refresh.nil?
97
+
98
+ # If refresh is disabled (0), just save current models if file doesn't exist
99
+ if refresh_days.zero?
100
+ save_models_to_json unless models_exist
101
+ return
81
102
  end
103
+
104
+ # Determine if refresh is needed:
105
+ # 1. Always refresh if models.json doesn't exist (initial setup)
106
+ # 2. Otherwise, refresh if enough time has passed
107
+ needs_refresh = if !models_exist
108
+ true # Initial refresh needed (no models.json)
109
+ else
110
+ Date.today > (last_refresh + refresh_days)
111
+ end
112
+
113
+ return unless needs_refresh
114
+
115
+ # Refresh models from RubyLLM (fetches latest model info)
116
+ RubyLLM.models.refresh!
117
+
118
+ # Save models to JSON file in aia_dir
119
+ save_models_to_json
120
+ end
121
+
122
+ def models_json_path
123
+ aia_dir = AIA.config.paths&.aia_dir
124
+ return nil if aia_dir.nil?
125
+
126
+ File.join(File.expand_path(aia_dir), 'models.json')
127
+ end
128
+
129
+ # Returns the last refresh date based on models.json modification time
130
+ def models_last_refresh
131
+ path = models_json_path
132
+ return nil if path.nil? || !File.exist?(path)
133
+
134
+ File.mtime(path).to_date
135
+ end
136
+
137
+ def save_models_to_json
138
+ return if models_json_path.nil?
139
+
140
+ aia_dir = File.expand_path(AIA.config.paths.aia_dir)
141
+ FileUtils.mkdir_p(aia_dir)
142
+
143
+ models_data = RubyLLM.models.all.map(&:to_h)
144
+
145
+ File.write(models_json_path, JSON.pretty_generate(models_data))
82
146
  end
83
147
 
84
148
 
@@ -170,7 +234,7 @@ module AIA
170
234
  @models = valid_chats.keys
171
235
 
172
236
  # Update the config to reflect only the valid models (keep as specs)
173
- AIA.config.model = @model_specs
237
+ # Note: models is an array, not directly settable - skip this update
174
238
 
175
239
  # Report successful models
176
240
  if failed_models.any?
@@ -194,16 +258,17 @@ module AIA
194
258
  @tools = []
195
259
 
196
260
  support_local_tools
197
- support_mcp_lazy
261
+ support_mcp_with_simple_flow # Parallel MCP connections via SimpleFlow
198
262
  filter_tools_by_allowed_list
199
263
  filter_tools_by_rejected_list
200
264
  drop_duplicate_tools
201
265
 
202
266
  if tools.empty?
203
267
  AIA.config.tool_names = ''
268
+ AIA.config.loaded_tools = []
204
269
  else
205
270
  AIA.config.tool_names = @tools.map(&:name).join(', ')
206
- AIA.config.tools = @tools
271
+ AIA.config.loaded_tools = @tools
207
272
  end
208
273
  end
209
274
 
@@ -221,13 +286,20 @@ module AIA
221
286
  AIA.config.tool_names = ''
222
287
  else
223
288
  AIA.config.tool_names = @tools.map(&:name).join(', ')
224
- AIA.config.tools = @tools
289
+ AIA.config.loaded_tools = @tools
225
290
  end
226
291
  end
227
292
 
228
293
 
229
294
  def support_local_tools
230
- @tools += ObjectSpace.each_object(Class).select do |klass|
295
+ # First, load any required libraries specified in config
296
+ load_require_libs
297
+
298
+ # Then, load tool files from tools.paths
299
+ load_tool_files
300
+
301
+ # Now scan ObjectSpace for RubyLLM::Tool subclasses
302
+ tool_classes = ObjectSpace.each_object(Class).select do |klass|
231
303
  next false unless klass < RubyLLM::Tool
232
304
 
233
305
  # Filter out tools that can't be instantiated without arguments
@@ -240,37 +312,379 @@ module AIA
240
312
  false
241
313
  end
242
314
  end
315
+
316
+ @tools += tool_classes
317
+ end
318
+
319
+ def load_require_libs
320
+ require_libs = AIA.config.require_libs
321
+ return if require_libs.nil? || require_libs.empty?
322
+
323
+ require_libs.each do |lib|
324
+ begin
325
+ # Activate gem and add to load path (bypasses Bundler's restrictions)
326
+ activate_gem_for_require(lib)
327
+
328
+ require lib
329
+
330
+ # After requiring, trigger tool loading if the library supports it
331
+ # This handles gems like shared_tools that use Zeitwerk lazy loading
332
+ trigger_tool_loading(lib)
333
+ rescue LoadError => e
334
+ warn "Warning: Failed to require library '#{lib}': #{e.message}"
335
+ warn "Hint: Make sure the gem is installed: gem install #{lib}"
336
+ rescue StandardError => e
337
+ warn "Warning: Error in library '#{lib}': #{e.class} - #{e.message}"
338
+ end
339
+ end
340
+ end
341
+
342
+ # Activate a gem and add its lib path to $LOAD_PATH
343
+ # This bypasses Bundler's restrictions on loading non-bundled gems
344
+ def activate_gem_for_require(lib)
345
+ # First try normal activation
346
+ return if Gem.try_activate(lib)
347
+
348
+ # Bundler intercepts Gem::Specification methods, so search gem dirs directly
349
+ gem_path = find_gem_path(lib)
350
+ if gem_path
351
+ lib_path = File.join(gem_path, 'lib')
352
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
353
+ end
243
354
  end
244
355
 
356
+ # Find gem path by searching gem directories directly
357
+ # This bypasses Bundler's restrictions
358
+ def find_gem_path(gem_name)
359
+ gem_dirs = Gem.path.flat_map do |base|
360
+ gems_dir = File.join(base, 'gems')
361
+ next [] unless File.directory?(gems_dir)
362
+
363
+ Dir.glob(File.join(gems_dir, "#{gem_name}-*")).select do |path|
364
+ File.directory?(path) && File.basename(path).match?(/^#{Regexp.escape(gem_name)}-[\d.]+/)
365
+ end
366
+ end
367
+
368
+ # Return the most recent version
369
+ gem_dirs.sort.last
370
+ end
245
371
 
246
- def support_mcp_lazy
247
- # Only load MCP tools if MCP servers are actually configured
248
- return if AIA.config.mcp_servers.nil? || AIA.config.mcp_servers.empty?
372
+ # Some tool libraries use lazy loading (e.g., Zeitwerk) and need explicit
373
+ # triggering to load tool classes into ObjectSpace
374
+ def trigger_tool_loading(lib)
375
+ # Convert lib name to constant (e.g., 'shared_tools' -> SharedTools)
376
+ const_name = lib.split(/[_-]/).map(&:capitalize).join
249
377
 
250
378
  begin
251
- RubyLLM::MCP.establish_connection
252
- @tools += RubyLLM::MCP.tools
253
- rescue StandardError => e
254
- warn "Warning: Failed to connect MCP clients: #{e.message}"
379
+ mod = Object.const_get(const_name)
380
+
381
+ # Try common methods that libraries use to load tools
382
+ if mod.respond_to?(:load_all_tools)
383
+ mod.load_all_tools
384
+ elsif mod.respond_to?(:tools)
385
+ # Calling .tools often triggers lazy loading
386
+ mod.tools
387
+ end
388
+ rescue NameError
389
+ # Constant doesn't exist, library might use different naming
390
+ end
391
+ end
392
+
393
+ def load_tool_files
394
+ paths = AIA.config.tools&.paths
395
+ return if paths.nil? || paths.empty?
396
+
397
+ paths.each do |path|
398
+ expanded_path = File.expand_path(path)
399
+ if File.exist?(expanded_path)
400
+ begin
401
+ require expanded_path
402
+ rescue LoadError, StandardError => e
403
+ warn "Warning: Failed to load tool file '#{path}': #{e.message}"
404
+ end
405
+ else
406
+ warn "Warning: Tool file not found: #{path}"
407
+ end
255
408
  end
256
409
  end
257
410
 
258
411
 
412
+ # Default timeout for MCP client initialization (in milliseconds)
413
+ # RubyLLM::MCP expects timeout in milliseconds (e.g., 8000 = 8 seconds)
414
+ # Using a short timeout to prevent slow servers from blocking startup
415
+ MCP_DEFAULT_TIMEOUT = 8_000 # 8 seconds (same as RubyLLM::MCP default)
416
+
259
417
  def support_mcp
418
+ if AIA.config.flags.no_mcp
419
+ logger.debug("MCP processing bypassed via --no-mcp flag")
420
+ return
421
+ end
422
+
423
+ logger.debug("Starting MCP connection via RubyLLM::MCP.establish_connection")
424
+ LoggerManager.configure_mcp_logger
425
+
426
+ start_time = Time.now
260
427
  RubyLLM::MCP.establish_connection
428
+ elapsed = Time.now - start_time
429
+
430
+ tool_count = RubyLLM::MCP.tools.size
261
431
  @tools += RubyLLM::MCP.tools
432
+
433
+ logger.info("MCP connection established", elapsed_seconds: elapsed.round(2), tool_count: tool_count)
262
434
  rescue StandardError => e
435
+ logger.error("Failed to connect MCP clients", error_class: e.class.name, error_message: e.message)
436
+ logger.debug("MCP connection error backtrace", backtrace: e.backtrace&.first(5))
263
437
  warn "Warning: Failed to connect MCP clients: #{e.message}"
264
438
  end
265
439
 
440
+ # =========================================================================
441
+ # SimpleFlow-based Parallel MCP Connection
442
+ # =========================================================================
443
+ # Uses fiber-based concurrency to connect to all MCP servers in parallel.
444
+ # This reduces total connection time from sum(timeouts) to max(timeouts).
445
+
446
+ def support_mcp_with_simple_flow
447
+ if AIA.config.flags.no_mcp
448
+ logger.debug("MCP processing bypassed via --no-mcp flag")
449
+ return
450
+ end
451
+
452
+ if AIA.config.mcp_servers.nil? || AIA.config.mcp_servers.empty?
453
+ logger.debug("No MCP servers configured, skipping MCP setup")
454
+ return
455
+ end
456
+
457
+ # Initialize tracking (kept for compatibility with Utility.robot)
458
+ AIA.config.connected_mcp_servers = []
459
+ AIA.config.failed_mcp_servers = []
460
+
461
+ servers = AIA.config.mcp_servers
462
+ server_names = servers.map { |s| s[:name] || s['name'] }.compact
463
+
464
+ logger.info("Starting parallel MCP connection", server_count: servers.size, servers: server_names)
465
+ $stderr.puts "MCP: Connecting to #{server_names.join(', ')}..."
466
+ $stderr.flush
467
+
468
+ LoggerManager.configure_mcp_logger
469
+
470
+ # Build steps array first (outside the block to preserve self reference)
471
+ # Each step is a [name, callable] pair for parallel execution
472
+ adapter = self
473
+ steps = servers.map do |server|
474
+ name = (server[:name] || server['name']).to_sym
475
+ logger.debug("Building connection step", server: name)
476
+ [name, adapter.send(:build_mcp_connection_step, server)]
477
+ end
478
+
479
+ # Build parallel pipeline - each server is independent (depends_on: :none)
480
+ # All servers will connect concurrently using fiber-based async
481
+ logger.debug("Creating SimpleFlow pipeline", step_count: steps.size)
482
+ pipeline = SimpleFlow::Pipeline.new(concurrency: :async) do
483
+ steps.each do |name, callable|
484
+ step name, callable, depends_on: :none
485
+ end
486
+ end
487
+
488
+ # Execute all connections in parallel
489
+ start_time = Time.now
490
+ initial_result = SimpleFlow::Result.new({ tools: [] })
491
+ final_result = pipeline.call_parallel(initial_result)
492
+ elapsed = Time.now - start_time
493
+
494
+ logger.info("Parallel MCP connection completed", elapsed_seconds: elapsed.round(2))
495
+
496
+ # Extract results and populate config arrays for compatibility
497
+ extract_mcp_results(final_result)
498
+ end
499
+
500
+ def build_mcp_connection_step(server)
501
+ # Capture logger in closure for use within the lambda
502
+ log = logger
503
+
504
+ ->(result) {
505
+ name = server[:name] || server['name']
506
+ start_time = Time.now
507
+
508
+ begin
509
+ log.debug("Registering MCP client", server: name)
510
+
511
+ # Register client with RubyLLM::MCP
512
+ client = register_single_mcp_client(server)
513
+
514
+ log.debug("Starting client connection", server: name)
515
+
516
+ # Start and verify connection
517
+ client.start
518
+ caps = client.capabilities
519
+ has_capabilities = caps && (caps.is_a?(Hash) ? !caps.empty? : caps)
520
+
521
+ elapsed = Time.now - start_time
522
+
523
+ if client.alive? && has_capabilities
524
+ # Success - get tools and record in context
525
+ tools = begin
526
+ client.tools
527
+ rescue StandardError => tool_err
528
+ log.warn("Failed to retrieve tools", server: name, error: tool_err.message)
529
+ []
530
+ end
531
+
532
+ tool_names = tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
533
+ log.info("Connected successfully", server: name, elapsed_seconds: elapsed.round(2), tool_count: tools.size)
534
+ log.debug("Available tools", server: name, tools: tool_names)
535
+
536
+ result
537
+ .with_context(name.to_sym, { status: :connected, tools: tools })
538
+ .continue(result.value)
539
+ else
540
+ # Connection issue - determine specific error
541
+ error = determine_mcp_connection_error(client, caps)
542
+ log.warn("Connection failed", server: name, elapsed_seconds: elapsed.round(2), error: error)
543
+ log.debug("Connection details", server: name, alive: client.alive?, capabilities: caps.inspect)
544
+
545
+ result
546
+ .with_error(name.to_sym, error)
547
+ .with_context(name.to_sym, { status: :failed })
548
+ .continue(result.value) # Continue to allow other servers
549
+ end
550
+ rescue StandardError => e
551
+ elapsed = Time.now - start_time
552
+ error_msg = e.message.downcase.include?('timeout') ?
553
+ "Connection timed out" : e.message
554
+
555
+ log.error("Connection exception", server: name, elapsed_seconds: elapsed.round(2), error_class: e.class.name, error: error_msg)
556
+ log.debug("Exception backtrace", server: name, backtrace: e.backtrace&.first(3))
557
+
558
+ result
559
+ .with_error(name.to_sym, error_msg)
560
+ .with_context(name.to_sym, { status: :failed })
561
+ .continue(result.value)
562
+ end
563
+ }
564
+ end
565
+
566
+ def register_single_mcp_client(server)
567
+ name = server[:name] || server['name']
568
+ command = server[:command] || server['command']
569
+ args = server[:args] || server['args'] || []
570
+ env = server[:env] || server['env'] || {}
571
+
572
+ raw_timeout = server[:timeout] || server['timeout'] ||
573
+ server[:request_timeout] || server['request_timeout'] ||
574
+ MCP_DEFAULT_TIMEOUT
575
+ request_timeout = raw_timeout.to_i < 1000 ? (raw_timeout.to_i * 1000) : raw_timeout.to_i
576
+ request_timeout = [request_timeout, 30_000].min
577
+
578
+ logger.debug("Configuring client", server: name, command: command, args: args, timeout_ms: request_timeout)
579
+ logger.debug("Environment variables", server: name, env_keys: env.keys) unless env.empty?
580
+
581
+ mcp_config = { command: command, args: Array(args) }
582
+ mcp_config[:env] = env unless env.empty?
583
+
584
+ begin
585
+ logger.debug("Adding client to RubyLLM::MCP with request_timeout", server: name)
586
+ RubyLLM::MCP.add_client(
587
+ name: name,
588
+ transport_type: :stdio,
589
+ config: mcp_config,
590
+ request_timeout: request_timeout,
591
+ start: false
592
+ )
593
+ rescue ArgumentError => e
594
+ # If request_timeout isn't supported in this version, try without it
595
+ if e.message.include?('timeout')
596
+ logger.debug("Retrying without request_timeout (unsupported in this RubyLLM::MCP version)", server: name)
597
+ RubyLLM::MCP.add_client(
598
+ name: name,
599
+ transport_type: :stdio,
600
+ config: mcp_config,
601
+ start: false
602
+ )
603
+ else
604
+ logger.error("Failed to add client", server: name, error: e.message)
605
+ raise
606
+ end
607
+ end
608
+
609
+ logger.debug("Client registered successfully", server: name)
610
+ RubyLLM::MCP.clients[name]
611
+ end
612
+
613
+ def determine_mcp_connection_error(client, caps)
614
+ if !client.alive?
615
+ "Connection failed"
616
+ elsif caps.nil?
617
+ "Connection timed out (no response)"
618
+ elsif caps.is_a?(Hash) && caps.empty?
619
+ "Connection timed out (empty capabilities)"
620
+ else
621
+ "Connection timed out (no capabilities received)"
622
+ end
623
+ end
624
+
625
+ def extract_mcp_results(result)
626
+ logger.debug("Extracting MCP connection results from SimpleFlow pipeline")
627
+ all_tools = []
628
+
629
+ result.context.each do |server_name, info|
630
+ name = server_name.to_s
631
+ if info[:status] == :connected
632
+ tool_count = (info[:tools] || []).size
633
+ logger.debug("Extracting tools from connected server", server: name, tool_count: tool_count)
634
+ AIA.config.connected_mcp_servers << name
635
+ all_tools.concat(info[:tools] || [])
636
+ end
637
+ end
638
+
639
+ result.errors.each do |server_name, messages|
640
+ logger.debug("Recording failure", server: server_name, error: messages.first)
641
+ AIA.config.failed_mcp_servers << {
642
+ name: server_name.to_s,
643
+ error: messages.first
644
+ }
645
+ end
646
+
647
+ @tools += all_tools
648
+
649
+ logger.info("MCP results",
650
+ connected_count: AIA.config.connected_mcp_servers.size,
651
+ failed_count: AIA.config.failed_mcp_servers.size,
652
+ total_tools: all_tools.size
653
+ )
654
+
655
+ # Report results
656
+ report_mcp_connection_results(all_tools.size)
657
+ end
658
+
659
+ def report_mcp_connection_results(tool_count)
660
+ if AIA.config.connected_mcp_servers.any?
661
+ logger.info("Successfully connected", servers: AIA.config.connected_mcp_servers)
662
+ $stderr.puts "MCP: Connected to #{AIA.config.connected_mcp_servers.join(', ')} (#{tool_count} tools)"
663
+ end
664
+
665
+ AIA.config.failed_mcp_servers.each do |failure|
666
+ logger.warn("Server failed", server: failure[:name], error: failure[:error])
667
+ $stderr.puts "⚠️ MCP: '#{failure[:name]}' failed - #{failure[:error]}"
668
+ end
669
+
670
+ if AIA.config.connected_mcp_servers.empty? && AIA.config.failed_mcp_servers.any?
671
+ logger.error("No MCP servers connected successfully")
672
+ $stderr.puts "MCP: No servers connected successfully"
673
+ end
674
+
675
+ $stderr.flush
676
+ end
266
677
 
267
678
  def drop_duplicate_tools
268
679
  seen_names = Set.new
269
680
  original_size = @tools.size
270
681
 
682
+ logger.debug("Checking tools for duplicates", tool_count: original_size)
683
+
271
684
  @tools.select! do |tool|
272
685
  tool_name = tool.name
273
686
  if seen_names.include?(tool_name)
687
+ logger.warn("Duplicate tool detected - keeping first occurrence only", tool: tool_name)
274
688
  warn "WARNING: Duplicate tool name detected: '#{tool_name}'. Only the first occurrence will be used."
275
689
  false
276
690
  else
@@ -280,7 +694,12 @@ module AIA
280
694
  end
281
695
 
282
696
  removed_count = original_size - @tools.size
283
- warn "Removed #{removed_count} duplicate tools" if removed_count > 0
697
+ if removed_count > 0
698
+ logger.info("Removed duplicate tools", removed_count: removed_count, remaining_count: @tools.size)
699
+ warn "Removed #{removed_count} duplicate tools"
700
+ else
701
+ logger.debug("No duplicate tools found")
702
+ end
284
703
  end
285
704
 
286
705
 
@@ -328,7 +747,7 @@ module AIA
328
747
  # Get role content using PromptHandler
329
748
  # Need to create PromptHandler instance if not already available
330
749
  prompt_handler = AIA::PromptHandler.new
331
- role_content = prompt_handler.load_role_for_model(spec, AIA.config.role)
750
+ role_content = prompt_handler.load_role_for_model(spec, AIA.config.prompts.role)
332
751
 
333
752
  return prompt unless role_content
334
753
 
@@ -409,7 +828,7 @@ module AIA
409
828
 
410
829
  def should_use_consensus_mode?
411
830
  # Only use consensus when explicitly enabled with --consensus flag
412
- AIA.config.consensus == true
831
+ AIA.config.flags.consensus == true
413
832
  end
414
833
 
415
834
  def generate_consensus_response(results)
@@ -464,14 +883,15 @@ module AIA
464
883
  end
465
884
 
466
885
  def format_individual_responses(results)
467
- # For metrics support, return a special structure if all results have token info
468
- has_metrics = results.values.all? { |r| r.respond_to?(:input_tokens) && r.respond_to?(:output_tokens) }
886
+ # Always collect metrics if available - display logic in session.rb decides whether to show them
887
+ # This ensures metrics are passed through even when flags.metrics is false
888
+ has_metrics = results.values.any? { |r| r.respond_to?(:input_tokens) && r.respond_to?(:output_tokens) }
469
889
 
470
- if has_metrics && AIA.config.show_metrics
890
+ if has_metrics
471
891
  # Return structured data that preserves metrics for multi-model
472
892
  format_multi_model_with_metrics(results)
473
893
  else
474
- # Original string formatting for non-metrics mode with role labels (ADR-005)
894
+ # No metrics available - return formatted string
475
895
  output = []
476
896
  results.each do |internal_id, result|
477
897
  # Get model spec to include role in output
@@ -518,16 +938,24 @@ module AIA
518
938
  formatted_content = []
519
939
  metrics_data = []
520
940
 
521
- results.each do |model_name, result|
522
- formatted_content << "from: #{model_name}"
523
- formatted_content << result.content
941
+ results.each do |internal_id, result|
942
+ # Get model spec to include role in output
943
+ spec = get_model_spec(internal_id)
944
+ display_name = format_model_display_name(spec)
945
+
946
+ formatted_content << "from: #{display_name}"
947
+ content = result.respond_to?(:content) ? result.content : result.to_s
948
+ formatted_content << content
524
949
  formatted_content << ""
525
950
 
526
951
  # Collect metrics for each model
952
+ # Use the actual model name (not internal_id) for cost calculation in RubyLLM::Models.find
953
+ actual_model = spec ? spec[:model] : internal_id
527
954
  metrics_data << {
528
- model_id: model_name,
529
- input_tokens: result.input_tokens,
530
- output_tokens: result.output_tokens
955
+ model_id: actual_model,
956
+ display_name: display_name,
957
+ input_tokens: result.respond_to?(:input_tokens) ? result.input_tokens : nil,
958
+ output_tokens: result.respond_to?(:output_tokens) ? result.output_tokens : nil
531
959
  }
532
960
  end
533
961
 
@@ -566,8 +994,8 @@ module AIA
566
994
  # Try using a TTS API if available
567
995
  # For now, we'll use a mock implementation
568
996
  File.write(output_file, 'Mock TTS audio content')
569
- if File.exist?(output_file) && system("which #{AIA.config.speak_command} > /dev/null 2>&1")
570
- system("#{AIA.config.speak_command} #{output_file}")
997
+ if File.exist?(output_file) && system("which #{AIA.config.audio.speak_command} > /dev/null 2>&1")
998
+ system("#{AIA.config.audio.speak_command} #{output_file}")
571
999
  end
572
1000
  "Audio generated and saved to: #{output_file}"
573
1001
  rescue StandardError => e
@@ -639,29 +1067,98 @@ module AIA
639
1067
 
640
1068
  private
641
1069
 
1070
+ # Helper to access the AIA logger for application-level logging
1071
+ def logger
1072
+ @logger ||= LoggerManager.aia_logger
1073
+ end
1074
+
642
1075
  def filter_tools_by_allowed_list
643
- return if AIA.config.allowed_tools.nil?
1076
+ allowed = AIA.config.tools.allowed
1077
+ return if allowed.nil? || allowed.empty?
1078
+
1079
+ # allowed_tools is now an array
1080
+ allowed_list = Array(allowed).map(&:strip)
644
1081
 
645
1082
  @tools.select! do |tool|
646
1083
  tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
647
- AIA.config.allowed_tools
648
- .split(',')
649
- .map(&:strip)
650
- .any? { |allowed| tool_name.include?(allowed) }
1084
+ allowed_list.any? { |allowed_pattern| tool_name.include?(allowed_pattern) }
651
1085
  end
652
1086
  end
653
1087
 
654
1088
 
655
1089
  def filter_tools_by_rejected_list
656
- return if AIA.config.rejected_tools.nil?
1090
+ rejected = AIA.config.tools.rejected
1091
+ return if rejected.nil? || rejected.empty?
1092
+
1093
+ # rejected_tools is now an array
1094
+ rejected_list = Array(rejected).map(&:strip)
657
1095
 
658
1096
  @tools.reject! do |tool|
659
1097
  tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
660
- AIA.config.rejected_tools
661
- .split(',')
662
- .map(&:strip)
663
- .any? { |rejected| tool_name.include?(rejected) }
1098
+ rejected_list.any? { |rejected_pattern| tool_name.include?(rejected_pattern) }
1099
+ end
1100
+ end
1101
+
1102
+
1103
+ # Handles tool execution crashes gracefully
1104
+ # Logs error with short traceback, repairs conversation, and returns error message
1105
+ def handle_tool_crash(chat_instance, exception)
1106
+ error_msg = "Tool error: #{exception.class} - #{exception.message}"
1107
+
1108
+ # Log error with short traceback (first 5 lines)
1109
+ warn "\n⚠️ #{error_msg}"
1110
+ if exception.backtrace
1111
+ short_trace = exception.backtrace.first(5).map { |line| " #{line}" }.join("\n")
1112
+ warn short_trace
1113
+ end
1114
+ warn "" # blank line for readability
1115
+
1116
+ # Repair incomplete tool calls to maintain conversation integrity
1117
+ repair_incomplete_tool_calls(chat_instance, error_msg)
1118
+
1119
+ # Return error message so conversation can continue
1120
+ error_msg
1121
+ end
1122
+
1123
+
1124
+ # Repairs conversation history when a tool call fails (timeout, error, etc.)
1125
+ # When an MCP tool times out, the conversation gets into an invalid state:
1126
+ # - Assistant message with tool_calls was added to history
1127
+ # - But no tool result message was added (because the tool failed)
1128
+ # - The API requires tool results for each tool_call_id
1129
+ # This method adds synthetic error tool results to fix the conversation.
1130
+ def repair_incomplete_tool_calls(chat_instance, error_message)
1131
+ return unless chat_instance.respond_to?(:messages)
1132
+
1133
+ messages = chat_instance.messages
1134
+ return if messages.empty?
1135
+
1136
+ # Find the last assistant message that has tool_calls
1137
+ last_assistant_with_tools = messages.reverse.find do |msg|
1138
+ msg.role == :assistant && msg.respond_to?(:tool_calls) && msg.tool_calls&.any?
1139
+ end
1140
+
1141
+ return unless last_assistant_with_tools
1142
+
1143
+ # Get the tool_call_ids that need results
1144
+ tool_call_ids = last_assistant_with_tools.tool_calls.keys
1145
+
1146
+ # Check which tool_call_ids already have results
1147
+ existing_tool_results = messages.select { |m| m.role == :tool }.map(&:tool_call_id).compact
1148
+
1149
+ # Add synthetic error results for any missing tool_call_ids
1150
+ tool_call_ids.each do |tool_call_id|
1151
+ next if existing_tool_results.include?(tool_call_id.to_s) || existing_tool_results.include?(tool_call_id)
1152
+
1153
+ # Add a synthetic tool result with the error message
1154
+ chat_instance.add_message(
1155
+ role: :tool,
1156
+ content: "Error: #{error_message}",
1157
+ tool_call_id: tool_call_id
1158
+ )
664
1159
  end
1160
+ rescue StandardError
1161
+ # Don't let repair failures cascade
665
1162
  end
666
1163
 
667
1164
 
@@ -704,26 +1201,30 @@ module AIA
704
1201
 
705
1202
 
706
1203
  def extract_models_config
707
- models_config = AIA.config.model
708
-
709
- # Handle backward compatibility
710
- if models_config.is_a?(String)
711
- # Old format: single string
712
- [{model: models_config, role: nil, instance: 1, internal_id: models_config}]
713
- elsif models_config.is_a?(Array)
714
- if models_config.empty?
715
- # Empty array - use default
716
- [{model: 'gpt-4o-mini', role: nil, instance: 1, internal_id: 'gpt-4o-mini'}]
717
- elsif models_config.first.is_a?(Hash)
718
- # New format: array of hashes with model specs
719
- models_config
720
- else
721
- # Old format: array of strings
722
- models_config.map { |m| {model: m, role: nil, instance: 1, internal_id: m} }
723
- end
724
- else
1204
+ # Use config.models which returns array of ModelSpec objects
1205
+ models_config = AIA.config.models
1206
+
1207
+ if models_config.nil? || models_config.empty?
725
1208
  # Fallback to default
726
1209
  [{model: 'gpt-4o-mini', role: nil, instance: 1, internal_id: 'gpt-4o-mini'}]
1210
+ else
1211
+ # Convert ModelSpec objects to hash format expected by adapter
1212
+ models_config.map do |spec|
1213
+ if spec.respond_to?(:name)
1214
+ # ModelSpec object
1215
+ {model: spec.name, role: spec.role, instance: spec.instance, internal_id: spec.internal_id}
1216
+ elsif spec.is_a?(Hash)
1217
+ # Hash format (legacy or from config.model accessor)
1218
+ model_name = spec[:model] || spec[:name]
1219
+ {model: model_name, role: spec[:role], instance: spec[:instance] || 1, internal_id: spec[:internal_id] || model_name}
1220
+ elsif spec.is_a?(String)
1221
+ # String format (legacy)
1222
+ {model: spec, role: nil, instance: 1, internal_id: spec}
1223
+ else
1224
+ # Unknown format, skip
1225
+ nil
1226
+ end
1227
+ end.compact
727
1228
  end
728
1229
  end
729
1230
 
@@ -769,8 +1270,10 @@ module AIA
769
1270
 
770
1271
  # Return the full response object to preserve token information
771
1272
  response
772
- rescue StandardError => e
773
- e.message
1273
+ rescue Exception => e # rubocop:disable Lint/RescueException
1274
+ # Catch ALL exceptions including LoadError, ScriptError, etc.
1275
+ # Tool crashes should not crash AIA - log and continue gracefully
1276
+ handle_tool_crash(chat_instance, e)
774
1277
  end
775
1278
 
776
1279
 
@@ -792,7 +1295,7 @@ module AIA
792
1295
  image_name = extract_image_path(text_prompt)
793
1296
 
794
1297
  begin
795
- image = RubyLLM.paint(text_prompt, size: AIA.config.image_size)
1298
+ image = RubyLLM.paint(text_prompt, size: AIA.config.image.size)
796
1299
  if image_name
797
1300
  image_path = image.save(image_name)
798
1301
  "Image generated and saved to: #{image_path}"
@@ -837,8 +1340,8 @@ module AIA
837
1340
  # NOTE: RubyLLM doesn't have a direct TTS feature
838
1341
  # TODO: This is a placeholder for a custom implementation
839
1342
  File.write(output_file, text_prompt)
840
- if File.exist?(output_file) && system("which #{AIA.config.speak_command} > /dev/null 2>&1")
841
- system("#{AIA.config.speak_command} #{output_file}")
1343
+ if File.exist?(output_file) && system("which #{AIA.config.audio.speak_command} > /dev/null 2>&1")
1344
+ system("#{AIA.config.audio.speak_command} #{output_file}")
842
1345
  end
843
1346
  "Audio generated and saved to: #{output_file}"
844
1347
  rescue StandardError => e