aia 0.9.23 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.version +1 -1
  3. data/CHANGELOG.md +95 -3
  4. data/README.md +187 -60
  5. data/bin/aia +6 -0
  6. data/docs/cli-reference.md +145 -72
  7. data/docs/configuration.md +156 -19
  8. data/docs/directives-reference.md +28 -8
  9. data/docs/examples/tools/index.md +2 -2
  10. data/docs/faq.md +11 -11
  11. data/docs/guides/available-models.md +11 -11
  12. data/docs/guides/basic-usage.md +18 -17
  13. data/docs/guides/chat.md +57 -11
  14. data/docs/guides/executable-prompts.md +15 -15
  15. data/docs/guides/first-prompt.md +2 -2
  16. data/docs/guides/getting-started.md +6 -6
  17. data/docs/guides/image-generation.md +24 -24
  18. data/docs/guides/local-models.md +2 -2
  19. data/docs/guides/models.md +96 -18
  20. data/docs/guides/tools.md +4 -4
  21. data/docs/index.md +2 -2
  22. data/docs/installation.md +2 -2
  23. data/docs/prompt_management.md +11 -11
  24. data/docs/security.md +3 -3
  25. data/docs/workflows-and-pipelines.md +1 -1
  26. data/examples/README.md +6 -6
  27. data/examples/headlines +3 -3
  28. data/lib/aia/aia_completion.bash +2 -2
  29. data/lib/aia/aia_completion.fish +4 -4
  30. data/lib/aia/aia_completion.zsh +2 -2
  31. data/lib/aia/chat_processor_service.rb +31 -21
  32. data/lib/aia/config/cli_parser.rb +403 -403
  33. data/lib/aia/config/config_section.rb +87 -0
  34. data/lib/aia/config/defaults.yml +219 -0
  35. data/lib/aia/config/defaults_loader.rb +147 -0
  36. data/lib/aia/config/mcp_parser.rb +151 -0
  37. data/lib/aia/config/model_spec.rb +67 -0
  38. data/lib/aia/config/validator.rb +185 -136
  39. data/lib/aia/config.rb +336 -17
  40. data/lib/aia/directive_processor.rb +14 -6
  41. data/lib/aia/directives/checkpoint.rb +283 -0
  42. data/lib/aia/directives/configuration.rb +27 -98
  43. data/lib/aia/directives/models.rb +15 -9
  44. data/lib/aia/directives/registry.rb +2 -0
  45. data/lib/aia/directives/utility.rb +25 -9
  46. data/lib/aia/directives/web_and_file.rb +50 -47
  47. data/lib/aia/logger.rb +328 -0
  48. data/lib/aia/prompt_handler.rb +18 -22
  49. data/lib/aia/ruby_llm_adapter.rb +584 -65
  50. data/lib/aia/session.rb +49 -156
  51. data/lib/aia/topic_context.rb +125 -0
  52. data/lib/aia/ui_presenter.rb +20 -16
  53. data/lib/aia/utility.rb +50 -18
  54. data/lib/aia.rb +91 -66
  55. data/lib/extensions/ruby_llm/modalities.rb +2 -0
  56. data/mcp_servers/apple-mcp.json +8 -0
  57. data/mcp_servers/mcp_server_chart.json +11 -0
  58. data/mcp_servers/playwright_one.json +8 -0
  59. data/mcp_servers/playwright_two.json +8 -0
  60. data/mcp_servers/tavily_mcp_server.json +8 -0
  61. metadata +85 -26
  62. data/lib/aia/config/base.rb +0 -288
  63. data/lib/aia/config/defaults.rb +0 -91
  64. data/lib/aia/config/file_loader.rb +0 -163
  65. data/lib/aia/context_manager.rb +0 -134
  66. data/mcp_servers/imcp.json +0 -7
  67. data/mcp_servers/launcher.json +0 -11
  68. data/mcp_servers/timeserver.json +0 -8
@@ -1,11 +1,14 @@
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
7
10
  class RubyLLMAdapter
8
- attr_reader :tools, :model_specs
11
+ attr_reader :tools, :model_specs, :chats
9
12
 
10
13
  def initialize
11
14
  @model_specs = extract_models_config # Full specs with role info
@@ -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,46 +286,405 @@ 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|
231
- klass < RubyLLM::Tool
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|
303
+ next false unless klass < RubyLLM::Tool
304
+
305
+ # Filter out tools that can't be instantiated without arguments
306
+ # RubyLLM calls tool.new without args, so we must verify each tool works
307
+ begin
308
+ klass.new
309
+ true
310
+ rescue ArgumentError, LoadError, StandardError
311
+ # Skip tools that require arguments or have missing dependencies
312
+ false
313
+ end
232
314
  end
315
+
316
+ @tools += tool_classes
233
317
  end
234
318
 
319
+ def load_require_libs
320
+ require_libs = AIA.config.require_libs
321
+ return if require_libs.nil? || require_libs.empty?
235
322
 
236
- def support_mcp_lazy
237
- # Only load MCP tools if MCP servers are actually configured
238
- return if AIA.config.mcp_servers.nil? || AIA.config.mcp_servers.empty?
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
354
+ end
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
371
+
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
239
377
 
240
378
  begin
241
- RubyLLM::MCP.establish_connection
242
- @tools += RubyLLM::MCP.tools
243
- rescue StandardError => e
244
- 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
245
390
  end
246
391
  end
247
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
408
+ end
409
+ end
410
+
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)
248
416
 
249
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
250
427
  RubyLLM::MCP.establish_connection
428
+ elapsed = Time.now - start_time
429
+
430
+ tool_count = RubyLLM::MCP.tools.size
251
431
  @tools += RubyLLM::MCP.tools
432
+
433
+ logger.info("MCP connection established", elapsed_seconds: elapsed.round(2), tool_count: tool_count)
252
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))
253
437
  warn "Warning: Failed to connect MCP clients: #{e.message}"
254
438
  end
255
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
256
677
 
257
678
  def drop_duplicate_tools
258
679
  seen_names = Set.new
259
680
  original_size = @tools.size
260
681
 
682
+ logger.debug("Checking tools for duplicates", tool_count: original_size)
683
+
261
684
  @tools.select! do |tool|
262
685
  tool_name = tool.name
263
686
  if seen_names.include?(tool_name)
687
+ logger.warn("Duplicate tool detected - keeping first occurrence only", tool: tool_name)
264
688
  warn "WARNING: Duplicate tool name detected: '#{tool_name}'. Only the first occurrence will be used."
265
689
  false
266
690
  else
@@ -270,7 +694,12 @@ module AIA
270
694
  end
271
695
 
272
696
  removed_count = original_size - @tools.size
273
- 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
274
703
  end
275
704
 
276
705
 
@@ -318,7 +747,7 @@ module AIA
318
747
  # Get role content using PromptHandler
319
748
  # Need to create PromptHandler instance if not already available
320
749
  prompt_handler = AIA::PromptHandler.new
321
- 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)
322
751
 
323
752
  return prompt unless role_content
324
753
 
@@ -399,7 +828,7 @@ module AIA
399
828
 
400
829
  def should_use_consensus_mode?
401
830
  # Only use consensus when explicitly enabled with --consensus flag
402
- AIA.config.consensus == true
831
+ AIA.config.flags.consensus == true
403
832
  end
404
833
 
405
834
  def generate_consensus_response(results)
@@ -454,14 +883,15 @@ module AIA
454
883
  end
455
884
 
456
885
  def format_individual_responses(results)
457
- # For metrics support, return a special structure if all results have token info
458
- 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) }
459
889
 
460
- if has_metrics && AIA.config.show_metrics
890
+ if has_metrics
461
891
  # Return structured data that preserves metrics for multi-model
462
892
  format_multi_model_with_metrics(results)
463
893
  else
464
- # Original string formatting for non-metrics mode with role labels (ADR-005)
894
+ # No metrics available - return formatted string
465
895
  output = []
466
896
  results.each do |internal_id, result|
467
897
  # Get model spec to include role in output
@@ -508,16 +938,24 @@ module AIA
508
938
  formatted_content = []
509
939
  metrics_data = []
510
940
 
511
- results.each do |model_name, result|
512
- formatted_content << "from: #{model_name}"
513
- 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
514
949
  formatted_content << ""
515
950
 
516
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
517
954
  metrics_data << {
518
- model_id: model_name,
519
- input_tokens: result.input_tokens,
520
- 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
521
959
  }
522
960
  end
523
961
 
@@ -556,8 +994,8 @@ module AIA
556
994
  # Try using a TTS API if available
557
995
  # For now, we'll use a mock implementation
558
996
  File.write(output_file, 'Mock TTS audio content')
559
- if File.exist?(output_file) && system("which #{AIA.config.speak_command} > /dev/null 2>&1")
560
- 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}")
561
999
  end
562
1000
  "Audio generated and saved to: #{output_file}"
563
1001
  rescue StandardError => e
@@ -629,23 +1067,98 @@ module AIA
629
1067
 
630
1068
  private
631
1069
 
1070
+ # Helper to access the AIA logger for application-level logging
1071
+ def logger
1072
+ @logger ||= LoggerManager.aia_logger
1073
+ end
1074
+
632
1075
  def filter_tools_by_allowed_list
633
- 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)
634
1081
 
635
1082
  @tools.select! do |tool|
636
1083
  tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
637
- AIA.config.allowed_tools.any? { |allowed| tool_name.include?(allowed) }
1084
+ allowed_list.any? { |allowed_pattern| tool_name.include?(allowed_pattern) }
638
1085
  end
639
1086
  end
640
1087
 
641
1088
 
642
1089
  def filter_tools_by_rejected_list
643
- 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)
644
1095
 
645
1096
  @tools.reject! do |tool|
646
1097
  tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
647
- AIA.config.rejected_tools.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
648
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
+ )
1159
+ end
1160
+ rescue StandardError
1161
+ # Don't let repair failures cascade
649
1162
  end
650
1163
 
651
1164
 
@@ -688,26 +1201,30 @@ module AIA
688
1201
 
689
1202
 
690
1203
  def extract_models_config
691
- models_config = AIA.config.model
692
-
693
- # Handle backward compatibility
694
- if models_config.is_a?(String)
695
- # Old format: single string
696
- [{model: models_config, role: nil, instance: 1, internal_id: models_config}]
697
- elsif models_config.is_a?(Array)
698
- if models_config.empty?
699
- # Empty array - use default
700
- [{model: 'gpt-4o-mini', role: nil, instance: 1, internal_id: 'gpt-4o-mini'}]
701
- elsif models_config.first.is_a?(Hash)
702
- # New format: array of hashes with model specs
703
- models_config
704
- else
705
- # Old format: array of strings
706
- models_config.map { |m| {model: m, role: nil, instance: 1, internal_id: m} }
707
- end
708
- 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?
709
1208
  # Fallback to default
710
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
711
1228
  end
712
1229
  end
713
1230
 
@@ -753,8 +1270,10 @@ module AIA
753
1270
 
754
1271
  # Return the full response object to preserve token information
755
1272
  response
756
- rescue StandardError => e
757
- 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)
758
1277
  end
759
1278
 
760
1279
 
@@ -776,7 +1295,7 @@ module AIA
776
1295
  image_name = extract_image_path(text_prompt)
777
1296
 
778
1297
  begin
779
- image = RubyLLM.paint(text_prompt, size: AIA.config.image_size)
1298
+ image = RubyLLM.paint(text_prompt, size: AIA.config.image.size)
780
1299
  if image_name
781
1300
  image_path = image.save(image_name)
782
1301
  "Image generated and saved to: #{image_path}"
@@ -821,8 +1340,8 @@ module AIA
821
1340
  # NOTE: RubyLLM doesn't have a direct TTS feature
822
1341
  # TODO: This is a placeholder for a custom implementation
823
1342
  File.write(output_file, text_prompt)
824
- if File.exist?(output_file) && system("which #{AIA.config.speak_command} > /dev/null 2>&1")
825
- 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}")
826
1345
  end
827
1346
  "Audio generated and saved to: #{output_file}"
828
1347
  rescue StandardError => e