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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/CHANGELOG.md +95 -3
- data/README.md +187 -60
- data/bin/aia +6 -0
- data/docs/cli-reference.md +145 -72
- data/docs/configuration.md +156 -19
- data/docs/directives-reference.md +28 -8
- 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/index.md +2 -2
- 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/checkpoint.rb +283 -0
- data/lib/aia/directives/configuration.rb +27 -98
- data/lib/aia/directives/models.rb +15 -9
- data/lib/aia/directives/registry.rb +2 -0
- data/lib/aia/directives/utility.rb +25 -9
- 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 +584 -65
- data/lib/aia/session.rb +49 -156
- data/lib/aia/topic_context.rb +125 -0
- 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 +85 -26
- data/lib/aia/config/base.rb +0 -288
- data/lib/aia/config/defaults.rb +0 -91
- data/lib/aia/config/file_loader.rb +0 -163
- data/lib/aia/context_manager.rb +0 -134
- 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,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('
|
|
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,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.
|
|
289
|
+
AIA.config.loaded_tools = @tools
|
|
225
290
|
end
|
|
226
291
|
end
|
|
227
292
|
|
|
228
293
|
|
|
229
294
|
def support_local_tools
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
458
|
-
|
|
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
|
|
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
|
-
#
|
|
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 |
|
|
512
|
-
|
|
513
|
-
|
|
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:
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if models_config.
|
|
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
|
|
757
|
-
|
|
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.
|
|
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
|