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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/CHANGELOG.md +84 -3
- data/README.md +179 -59
- data/bin/aia +6 -0
- data/docs/cli-reference.md +145 -72
- data/docs/configuration.md +156 -19
- data/docs/examples/tools/index.md +2 -2
- data/docs/faq.md +11 -11
- data/docs/guides/available-models.md +11 -11
- data/docs/guides/basic-usage.md +18 -17
- data/docs/guides/chat.md +57 -11
- data/docs/guides/executable-prompts.md +15 -15
- data/docs/guides/first-prompt.md +2 -2
- data/docs/guides/getting-started.md +6 -6
- data/docs/guides/image-generation.md +24 -24
- data/docs/guides/local-models.md +2 -2
- data/docs/guides/models.md +96 -18
- data/docs/guides/tools.md +4 -4
- data/docs/installation.md +2 -2
- data/docs/prompt_management.md +11 -11
- data/docs/security.md +3 -3
- data/docs/workflows-and-pipelines.md +1 -1
- data/examples/README.md +6 -6
- data/examples/headlines +3 -3
- data/lib/aia/aia_completion.bash +2 -2
- data/lib/aia/aia_completion.fish +4 -4
- data/lib/aia/aia_completion.zsh +2 -2
- data/lib/aia/chat_processor_service.rb +31 -21
- data/lib/aia/config/cli_parser.rb +403 -403
- data/lib/aia/config/config_section.rb +87 -0
- data/lib/aia/config/defaults.yml +219 -0
- data/lib/aia/config/defaults_loader.rb +147 -0
- data/lib/aia/config/mcp_parser.rb +151 -0
- data/lib/aia/config/model_spec.rb +67 -0
- data/lib/aia/config/validator.rb +185 -136
- data/lib/aia/config.rb +336 -17
- data/lib/aia/directive_processor.rb +14 -6
- data/lib/aia/directives/configuration.rb +24 -10
- data/lib/aia/directives/models.rb +3 -4
- data/lib/aia/directives/utility.rb +3 -2
- data/lib/aia/directives/web_and_file.rb +50 -47
- data/lib/aia/logger.rb +328 -0
- data/lib/aia/prompt_handler.rb +18 -22
- data/lib/aia/ruby_llm_adapter.rb +572 -69
- data/lib/aia/session.rb +9 -8
- data/lib/aia/ui_presenter.rb +20 -16
- data/lib/aia/utility.rb +50 -18
- data/lib/aia.rb +91 -66
- data/lib/extensions/ruby_llm/modalities.rb +2 -0
- data/mcp_servers/apple-mcp.json +8 -0
- data/mcp_servers/mcp_server_chart.json +11 -0
- data/mcp_servers/playwright_one.json +8 -0
- data/mcp_servers/playwright_two.json +8 -0
- data/mcp_servers/tavily_mcp_server.json +8 -0
- metadata +83 -25
- data/lib/aia/config/base.rb +0 -308
- data/lib/aia/config/defaults.rb +0 -91
- data/lib/aia/config/file_loader.rb +0 -163
- data/mcp_servers/imcp.json +0 -7
- data/mcp_servers/launcher.json +0 -11
- data/mcp_servers/timeserver.json +0 -8
data/lib/aia/ruby_llm_adapter.rb
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
289
|
+
AIA.config.loaded_tools = @tools
|
|
225
290
|
end
|
|
226
291
|
end
|
|
227
292
|
|
|
228
293
|
|
|
229
294
|
def support_local_tools
|
|
230
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
468
|
-
|
|
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
|
|
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
|
-
#
|
|
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 |
|
|
522
|
-
|
|
523
|
-
|
|
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:
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if models_config.
|
|
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
|
|
773
|
-
|
|
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.
|
|
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
|