aia 1.1.0 → 2.0.0.0.pre.alpha
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/.envrc +5 -1
- data/.loki +231 -0
- data/.quality/flay_baseline.txt +1 -0
- data/.quality/flog_baseline.txt +29 -0
- data/.quality/reek_baseline.txt +80 -0
- data/.rubocop.yml +116 -0
- data/.version +1 -1
- data/CHANGELOG.md +266 -42
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +266 -238
- data/Rakefile +118 -5
- data/architecture_review.md +314 -0
- data/bin/aia +16 -0
- data/docs/AGENTS.md +40 -0
- data/docs/advanced-prompting.md +67 -3
- data/docs/cli-reference.md +312 -56
- data/docs/configuration.md +130 -19
- data/docs/contributing.md +56 -2
- data/docs/directives-reference.md +593 -78
- data/docs/faq.md +85 -3
- data/docs/guides/available-models.md +1 -1
- data/docs/guides/basic-usage.md +6 -6
- data/docs/guides/chat.md +40 -16
- data/docs/guides/crew.md +239 -0
- data/docs/guides/executable-prompts.md +1 -1
- data/docs/guides/index.md +1 -0
- data/docs/guides/models.md +15 -0
- data/docs/index.md +29 -2
- data/docs/installation.md +44 -17
- data/docs/mcp-integration.md +40 -0
- data/docs/prompt_management.md +85 -86
- data/docs/security.md +47 -0
- data/docs/special_projects_guide.md +386 -0
- data/docs/tools-and-mcp-examples.md +23 -0
- data/docs/workflows-and-pipelines.md +84 -7
- data/examples/.gitignore +1 -0
- data/examples/00_setup_aia.sh +27 -44
- data/examples/11_multi_model.sh +4 -14
- data/examples/12_token_usage.sh +3 -12
- data/examples/18_tools.sh +10 -2
- data/examples/22_chat_mode.sh +0 -10
- data/examples/23_verify.sh +139 -0
- data/examples/24_decompose.sh +139 -0
- data/examples/25_spawn.sh +139 -0
- data/examples/26_debate.sh +97 -0
- data/examples/27_mention_routing.sh +157 -0
- data/examples/28_model_switching.sh +106 -0
- data/examples/29_agent_harness.sh +177 -0
- data/examples/README.md +65 -0
- data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
- data/examples/aia_config.yml +1 -1
- data/examples/aia_config_orchestrator.yml +45 -0
- data/examples/common.sh +19 -0
- data/examples/context/tech_stack.md +2 -2
- data/examples/prompts_dir/project_summary +2 -2
- data/examples/prompts_dir/roles/orchestrator.md +21 -0
- data/examples/requirements/sinatra_taskflow_app.md +139 -0
- data/examples/rules/01_classify_ruby.rb +16 -0
- data/examples/rules/02_prefer_claude_for_code.rb +19 -0
- data/examples/rules/03_gate_prompt_length.rb +19 -0
- data/examples/rules/04_tool_selection.rb +41 -0
- data/examples/rules/README.md +30 -0
- data/examples/run_all.sh +48 -15
- data/examples/tools/word_count_tool.rb +1 -1
- data/lib/AGENTS.md +57 -0
- data/lib/aia/chat_loop.rb +306 -159
- data/lib/aia/config/cli_parser.rb +174 -111
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +39 -46
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +121 -138
- data/lib/aia/config.rb +110 -145
- data/lib/aia/content_extractor.rb +153 -0
- data/lib/aia/cost_calculator.rb +38 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +166 -0
- data/lib/aia/delegate_handler.rb +112 -0
- data/lib/aia/directive.rb +33 -18
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +160 -20
- data/lib/aia/directives/context_directives.rb +38 -26
- data/lib/aia/directives/execution_directives.rb +136 -4
- data/lib/aia/directives/model_directives.rb +76 -34
- data/lib/aia/directives/trakflow_directives.rb +44 -0
- data/lib/aia/directives/utility_directives.rb +203 -6
- data/lib/aia/directives/web_and_file_directives.rb +96 -60
- data/lib/aia/errors.rb +15 -0
- data/lib/aia/fact_asserter.rb +27 -0
- data/lib/aia/fzf.rb +9 -31
- data/lib/aia/handler_context.rb +17 -0
- data/lib/aia/handler_protocol.rb +19 -0
- data/lib/aia/history_transfer.rb +55 -0
- data/lib/aia/input_collector.rb +3 -3
- data/lib/aia/layered_orchestrator.rb +448 -0
- data/lib/aia/logger.rb +24 -4
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +305 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_utility.rb +57 -0
- data/lib/aia/mention_router.rb +260 -0
- data/lib/aia/model_alias_registry.rb +97 -0
- data/lib/aia/model_switch_handler.rb +100 -0
- data/lib/aia/network_builder.rb +155 -0
- data/lib/aia/network_memory_manager.rb +55 -0
- data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
- data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
- data/lib/aia/pipeline_orchestrator.rb +262 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +208 -0
- data/lib/aia/prompt_decomposer.rb +157 -0
- data/lib/aia/prompt_handler.rb +19 -39
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +334 -0
- data/lib/aia/robot_namer.rb +116 -0
- data/lib/aia/session.rb +83 -17
- data/lib/aia/session_tracker.rb +209 -0
- data/lib/aia/similarity_scorer.rb +39 -0
- data/lib/aia/skill_utils.rb +105 -1
- data/lib/aia/spawn_handler.rb +129 -0
- data/lib/aia/spawn_spec_parser.rb +65 -0
- data/lib/aia/special_mode_handler.rb +302 -0
- data/lib/aia/startup_coordinator.rb +150 -0
- data/lib/aia/streaming_runner.rb +169 -0
- data/lib/aia/system_prompt_assembler.rb +88 -0
- data/lib/aia/task_coordinator.rb +202 -0
- data/lib/aia/task_decomposer.rb +57 -0
- data/lib/aia/task_executor.rb +51 -0
- data/lib/aia/tfidf_math.rb +27 -0
- data/lib/aia/tool_filter/tfidf.rb +116 -0
- data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
- data/lib/aia/tool_filter.rb +82 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +143 -0
- data/lib/aia/tool_loader.rb +210 -0
- data/lib/aia/tool_utility.rb +30 -0
- data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
- data/lib/aia/tools/recruit_robot_tool.rb +60 -0
- data/lib/aia/tools/reskill_robot_tool.rb +44 -0
- data/lib/aia/tools/task_board_tool.rb +114 -0
- data/lib/aia/trakflow_bridge.rb +173 -0
- data/lib/aia/turn_state.rb +94 -0
- data/lib/aia/ui_presenter.rb +166 -198
- data/lib/aia/utility.rb +134 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
- data/lib/aia/verification_network.rb +58 -0
- data/lib/aia.rb +108 -63
- data/mkdocs.yml +1 -0
- metadata +179 -56
- data/justfile +0 -215
- data/lib/aia/adapter/chat_execution.rb +0 -242
- data/lib/aia/adapter/error_handler.rb +0 -68
- data/lib/aia/adapter/gem_activator.rb +0 -57
- data/lib/aia/adapter/mcp_connector.rb +0 -274
- data/lib/aia/adapter/modality_handlers.rb +0 -167
- data/lib/aia/adapter/model_registry.rb +0 -81
- data/lib/aia/adapter/multi_model_chat.rb +0 -218
- data/lib/aia/adapter/provider_configurator.rb +0 -59
- data/lib/aia/adapter/tool_filter.rb +0 -85
- data/lib/aia/adapter/tool_loader.rb +0 -90
- data/lib/aia/chat_processor_service.rb +0 -164
- data/lib/aia/prompt_pipeline.rb +0 -183
- data/lib/aia/ruby_llm_adapter.rb +0 -95
- data/lib/extensions/openstruct_merge.rb +0 -48
- data/lib/extensions/ruby_llm/.irbrc +0 -56
- data/lib/extensions/ruby_llm/modalities.rb +0 -36
- data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
- data/lib/refinements/string.rb +0 -16
- data/main.just +0 -76
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
# lib/aia/adapter/modality_handlers.rb
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require 'tempfile'
|
|
5
|
-
|
|
6
|
-
module AIA
|
|
7
|
-
module Adapter
|
|
8
|
-
module ModalityHandlers
|
|
9
|
-
def transcribe(audio_file)
|
|
10
|
-
# Use the first model for transcription
|
|
11
|
-
first_model = @models.first
|
|
12
|
-
@chats[first_model].ask('Transcribe this audio', with: audio_file).content
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def speak(_text)
|
|
16
|
-
# NOTE: RubyLLM doesn't have a direct text-to-speech feature
|
|
17
|
-
# This is a placeholder for a custom implementation or external service
|
|
18
|
-
tmpfile = Tempfile.new(['aia-tts-', '.mp3'])
|
|
19
|
-
begin
|
|
20
|
-
tmpfile.write('Mock TTS audio content')
|
|
21
|
-
tmpfile.close
|
|
22
|
-
speak_cmd = AIA.config.audio.speak_command
|
|
23
|
-
if system('which', speak_cmd, out: File::NULL, err: File::NULL)
|
|
24
|
-
system(speak_cmd, tmpfile.path)
|
|
25
|
-
end
|
|
26
|
-
"Audio generated successfully"
|
|
27
|
-
rescue StandardError => e
|
|
28
|
-
"Error generating audio: #{e.message}"
|
|
29
|
-
ensure
|
|
30
|
-
tmpfile.unlink
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def extract_text_prompt(prompt)
|
|
37
|
-
if prompt.is_a?(String)
|
|
38
|
-
prompt
|
|
39
|
-
elsif prompt.is_a?(Hash) && prompt[:text]
|
|
40
|
-
prompt[:text]
|
|
41
|
-
elsif prompt.is_a?(Hash) && prompt[:content]
|
|
42
|
-
prompt[:content]
|
|
43
|
-
else
|
|
44
|
-
prompt.to_s
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
#########################################
|
|
49
|
-
## text
|
|
50
|
-
|
|
51
|
-
def text_to_text_single(prompt, model_name)
|
|
52
|
-
chat_instance = @chats[model_name]
|
|
53
|
-
text_prompt = extract_text_prompt(prompt)
|
|
54
|
-
|
|
55
|
-
response = if AIA.config.context_files.empty?
|
|
56
|
-
chat_instance.ask(text_prompt)
|
|
57
|
-
else
|
|
58
|
-
chat_instance.ask(text_prompt, with: AIA.config.context_files)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Return the full response object to preserve token information
|
|
62
|
-
response
|
|
63
|
-
rescue StandardError => e
|
|
64
|
-
# Tool crashes should not crash AIA - log and continue gracefully
|
|
65
|
-
handle_tool_crash(chat_instance, e)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
#########################################
|
|
69
|
-
## Image
|
|
70
|
-
|
|
71
|
-
def extract_image_path(prompt)
|
|
72
|
-
if prompt.is_a?(String)
|
|
73
|
-
match = prompt.match(%r{\b[\w/.\-_]+?\.(jpg|jpeg|png|gif|webp)\b}i)
|
|
74
|
-
match ? match[0] : nil
|
|
75
|
-
elsif prompt.is_a?(Hash)
|
|
76
|
-
prompt[:image] || prompt[:image_path]
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def text_to_image_single(prompt, model_name)
|
|
81
|
-
text_prompt = extract_text_prompt(prompt)
|
|
82
|
-
image_name = extract_image_path(text_prompt)
|
|
83
|
-
|
|
84
|
-
begin
|
|
85
|
-
image = RubyLLM.paint(text_prompt, size: AIA.config.image.size)
|
|
86
|
-
if image_name
|
|
87
|
-
image_path = image.save(image_name)
|
|
88
|
-
"Image generated and saved to: #{image_path}"
|
|
89
|
-
else
|
|
90
|
-
"Image generated and available at: #{image.url}"
|
|
91
|
-
end
|
|
92
|
-
rescue StandardError => e
|
|
93
|
-
"Error generating image: #{e.message}"
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def image_to_text_single(prompt, model_name)
|
|
98
|
-
image_path = extract_image_path(prompt)
|
|
99
|
-
text_prompt = extract_text_prompt(prompt)
|
|
100
|
-
|
|
101
|
-
if image_path && File.exist?(image_path)
|
|
102
|
-
begin
|
|
103
|
-
@chats[model_name].ask(text_prompt, with: image_path).content
|
|
104
|
-
rescue StandardError => e
|
|
105
|
-
"Error analyzing image: #{e.message}"
|
|
106
|
-
end
|
|
107
|
-
else
|
|
108
|
-
text_to_text_single(prompt, model_name)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
#########################################
|
|
113
|
-
## audio
|
|
114
|
-
|
|
115
|
-
def audio_file?(filepath)
|
|
116
|
-
filepath.to_s.downcase.end_with?('.mp3', '.wav', '.m4a', '.flac')
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def text_to_audio_single(prompt, model_name)
|
|
120
|
-
text_prompt = extract_text_prompt(prompt)
|
|
121
|
-
|
|
122
|
-
# NOTE: RubyLLM doesn't have a direct TTS feature
|
|
123
|
-
# TODO: This is a placeholder for a custom implementation
|
|
124
|
-
tmpfile = Tempfile.new(['aia-tts-', '.mp3'])
|
|
125
|
-
begin
|
|
126
|
-
tmpfile.write(text_prompt)
|
|
127
|
-
tmpfile.close
|
|
128
|
-
speak_cmd = AIA.config.audio.speak_command
|
|
129
|
-
if system('which', speak_cmd, out: File::NULL, err: File::NULL)
|
|
130
|
-
system(speak_cmd, tmpfile.path)
|
|
131
|
-
end
|
|
132
|
-
"Audio generated successfully"
|
|
133
|
-
rescue StandardError => e
|
|
134
|
-
"Error generating audio: #{e.message}"
|
|
135
|
-
ensure
|
|
136
|
-
tmpfile.unlink
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def audio_to_text_single(prompt, model_name)
|
|
141
|
-
text_prompt = extract_text_prompt(prompt)
|
|
142
|
-
text_prompt = 'Transcribe this audio' if text_prompt.nil? || text_prompt.empty?
|
|
143
|
-
|
|
144
|
-
# TODO: I don't think that "prompt" would ever be an audio filepath.
|
|
145
|
-
# Check prompt to see if it is a PromptManager object that has context_files
|
|
146
|
-
|
|
147
|
-
if prompt.is_a?(String) &&
|
|
148
|
-
File.exist?(prompt) &&
|
|
149
|
-
audio_file?(prompt)
|
|
150
|
-
begin
|
|
151
|
-
response = if AIA.config.context_files.empty?
|
|
152
|
-
@chats[model_name].ask(text_prompt)
|
|
153
|
-
else
|
|
154
|
-
@chats[model_name].ask(text_prompt, with: AIA.config.context_files)
|
|
155
|
-
end
|
|
156
|
-
response.content
|
|
157
|
-
rescue StandardError => e
|
|
158
|
-
"Error transcribing audio: #{e.message}"
|
|
159
|
-
end
|
|
160
|
-
else
|
|
161
|
-
# Fall back to regular chat if no valid audio file is found
|
|
162
|
-
text_to_text_single(prompt, model_name)
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
end
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# lib/aia/adapter/model_registry.rb
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require 'fileutils'
|
|
5
|
-
require 'json'
|
|
6
|
-
|
|
7
|
-
module AIA
|
|
8
|
-
module Adapter
|
|
9
|
-
class ModelRegistry
|
|
10
|
-
def refresh
|
|
11
|
-
return if models_json_path.nil? # Skip if no aia_dir configured
|
|
12
|
-
|
|
13
|
-
# On first use, copy the bundled models.json from RubyLLM (no API calls)
|
|
14
|
-
copy_bundled_models_to_local unless File.exist?(models_json_path)
|
|
15
|
-
|
|
16
|
-
# Point RubyLLM at our local registry file for all model lookups
|
|
17
|
-
RubyLLM.config.model_registry_file = models_json_path
|
|
18
|
-
|
|
19
|
-
# Coerce refresh_days to integer (env vars come as strings)
|
|
20
|
-
refresh_days = AIA.config.registry.refresh
|
|
21
|
-
refresh_days = refresh_days.to_i if refresh_days.respond_to?(:to_i)
|
|
22
|
-
refresh_days ||= 7 # Default to 7 days if nil
|
|
23
|
-
|
|
24
|
-
# If refresh is disabled (0), just use the local file as-is
|
|
25
|
-
return if refresh_days.zero?
|
|
26
|
-
|
|
27
|
-
# Only refresh from provider APIs when enough days have elapsed
|
|
28
|
-
last_refresh = models_last_refresh
|
|
29
|
-
return unless last_refresh.nil? || Date.today > (last_refresh + refresh_days)
|
|
30
|
-
|
|
31
|
-
# Refresh models from provider APIs and models.dev
|
|
32
|
-
RubyLLM.models.refresh!
|
|
33
|
-
|
|
34
|
-
# Save refreshed models to our local JSON file
|
|
35
|
-
save_models_to_json
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def models_json_path
|
|
39
|
-
aia_dir = AIA.config.paths&.aia_dir
|
|
40
|
-
return nil if aia_dir.nil?
|
|
41
|
-
|
|
42
|
-
File.join(File.expand_path(aia_dir), 'models.json')
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Returns the last refresh date based on models.json modification time
|
|
46
|
-
def models_last_refresh
|
|
47
|
-
path = models_json_path
|
|
48
|
-
return nil if path.nil? || !File.exist?(path)
|
|
49
|
-
|
|
50
|
-
File.mtime(path).to_date
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def copy_bundled_models_to_local
|
|
54
|
-
aia_dir = File.expand_path(AIA.config.paths.aia_dir)
|
|
55
|
-
FileUtils.mkdir_p(aia_dir)
|
|
56
|
-
|
|
57
|
-
# RubyLLM.config.model_registry_file points to the gem's bundled models.json
|
|
58
|
-
# before we redirect it to our local copy
|
|
59
|
-
bundled_path = RubyLLM.config.model_registry_file
|
|
60
|
-
|
|
61
|
-
if bundled_path && File.exist?(bundled_path)
|
|
62
|
-
FileUtils.cp(bundled_path, models_json_path)
|
|
63
|
-
else
|
|
64
|
-
# Fallback: save whatever RubyLLM has loaded from its bundled data
|
|
65
|
-
save_models_to_json
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def save_models_to_json
|
|
70
|
-
return if models_json_path.nil?
|
|
71
|
-
|
|
72
|
-
aia_dir = File.expand_path(AIA.config.paths.aia_dir)
|
|
73
|
-
FileUtils.mkdir_p(aia_dir)
|
|
74
|
-
|
|
75
|
-
models_data = RubyLLM.models.all.map(&:to_h)
|
|
76
|
-
|
|
77
|
-
File.write(models_json_path, JSON.pretty_generate(models_data))
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
# lib/aia/adapter/multi_model_chat.rb
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require 'async'
|
|
5
|
-
|
|
6
|
-
module AIA
|
|
7
|
-
module Adapter
|
|
8
|
-
module MultiModelChat
|
|
9
|
-
# Helper class to carry multi-model response with metrics
|
|
10
|
-
class MultiModelResponse
|
|
11
|
-
attr_reader :content, :metrics_list
|
|
12
|
-
|
|
13
|
-
def initialize(content, metrics_list)
|
|
14
|
-
@content = content
|
|
15
|
-
@metrics_list = metrics_list
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def multi_model?
|
|
19
|
-
true
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Prepend role content to prompt for a specific model (ADR-005)
|
|
24
|
-
def prepend_model_role(prompt, internal_id)
|
|
25
|
-
# Get model spec to find role
|
|
26
|
-
spec = get_model_spec(internal_id)
|
|
27
|
-
return prompt unless spec && spec[:role]
|
|
28
|
-
|
|
29
|
-
# Get role content using PromptHandler
|
|
30
|
-
prompt_handler = AIA::PromptHandler.new
|
|
31
|
-
role_content = prompt_handler.load_role_for_model(spec, AIA.config.prompts.role)
|
|
32
|
-
|
|
33
|
-
return prompt unless role_content
|
|
34
|
-
|
|
35
|
-
# Prepend role to prompt based on prompt type
|
|
36
|
-
if prompt.is_a?(String)
|
|
37
|
-
"#{role_content}\n\n#{prompt}"
|
|
38
|
-
elsif prompt.is_a?(Array)
|
|
39
|
-
prepend_role_to_conversation(prompt, role_content)
|
|
40
|
-
else
|
|
41
|
-
prompt
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def prepend_role_to_conversation(conversation, role_content)
|
|
46
|
-
# Find the first user message and prepend role
|
|
47
|
-
modified = conversation.dup
|
|
48
|
-
first_user_index = modified.find_index { |msg| msg[:role] == "user" || msg["role"] == "user" }
|
|
49
|
-
|
|
50
|
-
if first_user_index
|
|
51
|
-
msg = modified[first_user_index].dup
|
|
52
|
-
content_key = msg.key?(:content) ? :content : "content"
|
|
53
|
-
|
|
54
|
-
msg[content_key] = "#{role_content}\n\n#{msg[content_key]}"
|
|
55
|
-
modified[first_user_index] = msg
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
modified
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def multi_model_chat(prompt_or_contexts)
|
|
62
|
-
results = {}
|
|
63
|
-
|
|
64
|
-
# Check if we're receiving per-model contexts (Hash) or shared prompt (String/Array) - ADR-002 revised
|
|
65
|
-
per_model_contexts = prompt_or_contexts.is_a?(Hash) &&
|
|
66
|
-
prompt_or_contexts.keys.all? { |k| @models.include?(k) }
|
|
67
|
-
|
|
68
|
-
Async do |task|
|
|
69
|
-
@models.each do |internal_id|
|
|
70
|
-
task.async do
|
|
71
|
-
begin
|
|
72
|
-
# Use model-specific context if available, otherwise shared prompt
|
|
73
|
-
prompt = if per_model_contexts
|
|
74
|
-
prompt_or_contexts[internal_id]
|
|
75
|
-
else
|
|
76
|
-
prompt_or_contexts
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Add per-model role if specified (ADR-005)
|
|
80
|
-
prompt = prepend_model_role(prompt, internal_id)
|
|
81
|
-
|
|
82
|
-
result = single_model_chat(prompt, internal_id)
|
|
83
|
-
results[internal_id] = result
|
|
84
|
-
rescue StandardError => e
|
|
85
|
-
results[internal_id] = "Error with #{internal_id}: #{e.message}"
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Format and return results from all models
|
|
92
|
-
format_multi_model_results(results)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def format_multi_model_results(results)
|
|
96
|
-
if should_use_consensus_mode?
|
|
97
|
-
generate_consensus_response(results)
|
|
98
|
-
else
|
|
99
|
-
format_individual_responses(results)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def should_use_consensus_mode?
|
|
104
|
-
AIA.config.flags.consensus == true
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def generate_consensus_response(results)
|
|
108
|
-
primary_model = @models.first
|
|
109
|
-
primary_chat = @chats[primary_model]
|
|
110
|
-
|
|
111
|
-
consensus_prompt = build_consensus_prompt(results)
|
|
112
|
-
|
|
113
|
-
begin
|
|
114
|
-
consensus_result = primary_chat.ask(consensus_prompt).content
|
|
115
|
-
"from: #{primary_model}\n#{consensus_result}"
|
|
116
|
-
rescue StandardError => e
|
|
117
|
-
"Error generating consensus: #{e.message}\n\n" + format_individual_responses(results)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def build_consensus_prompt(results)
|
|
122
|
-
prompt_parts = []
|
|
123
|
-
prompt_parts << "You are tasked with creating a consensus response based on multiple AI model responses to the same query."
|
|
124
|
-
prompt_parts << "Please analyze the following responses and provide a unified, comprehensive answer that:"
|
|
125
|
-
prompt_parts << "- Incorporates the best insights from all models"
|
|
126
|
-
prompt_parts << "- Resolves any contradictions with clear reasoning"
|
|
127
|
-
prompt_parts << "- Provides additional context or clarification when helpful"
|
|
128
|
-
prompt_parts << "- Maintains accuracy and avoids speculation"
|
|
129
|
-
prompt_parts << ""
|
|
130
|
-
prompt_parts << "Model responses:"
|
|
131
|
-
prompt_parts << ""
|
|
132
|
-
|
|
133
|
-
results.each do |model_name, result|
|
|
134
|
-
content = if result.respond_to?(:content)
|
|
135
|
-
result.content
|
|
136
|
-
else
|
|
137
|
-
result.to_s
|
|
138
|
-
end
|
|
139
|
-
next if content.start_with?("Error with")
|
|
140
|
-
prompt_parts << "#{model_name}:"
|
|
141
|
-
prompt_parts << content
|
|
142
|
-
prompt_parts << ""
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
prompt_parts << "Please provide your consensus response:"
|
|
146
|
-
prompt_parts.join("\n")
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def format_individual_responses(results)
|
|
150
|
-
has_metrics = results.values.any? { |r| r.respond_to?(:input_tokens) && r.respond_to?(:output_tokens) }
|
|
151
|
-
|
|
152
|
-
if has_metrics
|
|
153
|
-
format_multi_model_with_metrics(results)
|
|
154
|
-
else
|
|
155
|
-
output = []
|
|
156
|
-
results.each do |internal_id, result|
|
|
157
|
-
spec = get_model_spec(internal_id)
|
|
158
|
-
display_name = format_model_display_name(spec)
|
|
159
|
-
|
|
160
|
-
output << "from: #{display_name}"
|
|
161
|
-
content = if result.respond_to?(:content)
|
|
162
|
-
result.content
|
|
163
|
-
else
|
|
164
|
-
result.to_s
|
|
165
|
-
end
|
|
166
|
-
output << content
|
|
167
|
-
output << "" # Add blank line between results
|
|
168
|
-
end
|
|
169
|
-
output.join("\n")
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# Format display name with instance number and role (ADR-005)
|
|
174
|
-
def format_model_display_name(spec)
|
|
175
|
-
return spec unless spec.is_a?(Hash)
|
|
176
|
-
|
|
177
|
-
model_name = spec[:model]
|
|
178
|
-
instance = spec[:instance]
|
|
179
|
-
role = spec[:role]
|
|
180
|
-
|
|
181
|
-
display = if instance > 1
|
|
182
|
-
"#{model_name} ##{instance}"
|
|
183
|
-
else
|
|
184
|
-
model_name
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
display += " (#{role})" if role
|
|
188
|
-
|
|
189
|
-
display
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def format_multi_model_with_metrics(results)
|
|
193
|
-
formatted_content = []
|
|
194
|
-
metrics_data = []
|
|
195
|
-
|
|
196
|
-
results.each do |internal_id, result|
|
|
197
|
-
spec = get_model_spec(internal_id)
|
|
198
|
-
display_name = format_model_display_name(spec)
|
|
199
|
-
|
|
200
|
-
formatted_content << "from: #{display_name}"
|
|
201
|
-
content = result.respond_to?(:content) ? result.content : result.to_s
|
|
202
|
-
formatted_content << content
|
|
203
|
-
formatted_content << ""
|
|
204
|
-
|
|
205
|
-
actual_model = spec ? spec[:model] : internal_id
|
|
206
|
-
metrics_data << {
|
|
207
|
-
model_id: actual_model,
|
|
208
|
-
display_name: display_name,
|
|
209
|
-
input_tokens: result.respond_to?(:input_tokens) ? result.input_tokens : nil,
|
|
210
|
-
output_tokens: result.respond_to?(:output_tokens) ? result.output_tokens : nil
|
|
211
|
-
}
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
MultiModelResponse.new(formatted_content.join("\n"), metrics_data)
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# lib/aia/adapter/provider_configurator.rb
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
module AIA
|
|
5
|
-
module Adapter
|
|
6
|
-
class ProviderConfigurator
|
|
7
|
-
def self.configure
|
|
8
|
-
# TODO: Add some of these configuration items to AIA.config
|
|
9
|
-
# Note: RubyLLM supports specific providers. Use provider prefix (e.g., "xai/grok-beta")
|
|
10
|
-
# for providers not directly configured here.
|
|
11
|
-
RubyLLM.configure do |config|
|
|
12
|
-
config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
|
|
13
|
-
config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil)
|
|
14
|
-
config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil)
|
|
15
|
-
config.gpustack_api_key = ENV.fetch('GPUSTACK_API_KEY', nil)
|
|
16
|
-
config.mistral_api_key = ENV.fetch('MISTRAL_API_KEY', nil)
|
|
17
|
-
config.openrouter_api_key = ENV.fetch('OPEN_ROUTER_API_KEY', nil)
|
|
18
|
-
config.perplexity_api_key = ENV.fetch('PERPLEXITY_API_KEY', nil)
|
|
19
|
-
|
|
20
|
-
# These providers require a little something extra
|
|
21
|
-
config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
|
|
22
|
-
config.openai_organization_id = ENV.fetch('OPENAI_ORGANIZATION_ID', nil)
|
|
23
|
-
config.openai_project_id = ENV.fetch('OPENAI_PROJECT_ID', nil)
|
|
24
|
-
|
|
25
|
-
config.bedrock_api_key = ENV.fetch('BEDROCK_ACCESS_KEY_ID', nil)
|
|
26
|
-
config.bedrock_secret_key = ENV.fetch('BEDROCK_SECRET_ACCESS_KEY', nil)
|
|
27
|
-
config.bedrock_region = ENV.fetch('BEDROCK_REGION', nil)
|
|
28
|
-
config.bedrock_session_token = ENV.fetch('BEDROCK_SESSION_TOKEN', nil)
|
|
29
|
-
|
|
30
|
-
# Ollama is based upon the OpenAI API so it needs to over-ride a few things
|
|
31
|
-
config.ollama_api_base = ENV.fetch('OLLAMA_API_BASE', nil)
|
|
32
|
-
|
|
33
|
-
# --- Custom OpenAI Endpoint ---
|
|
34
|
-
# Use this for Azure OpenAI, proxies, or self-hosted models via OpenAI-compatible APIs.
|
|
35
|
-
# For osaurus: Use model name prefix "osaurus/" and set OSAURUS_API_BASE env var
|
|
36
|
-
# For LM Studio: Use model name prefix "lms/" and set LMS_API_BASE env var
|
|
37
|
-
config.openai_api_base = ENV.fetch('OPENAI_API_BASE', nil) # e.g., "https://your-azure.openai.azure.com"
|
|
38
|
-
|
|
39
|
-
# --- Connection Settings ---
|
|
40
|
-
config.request_timeout = 120 # Request timeout in seconds (default: 120)
|
|
41
|
-
config.max_retries = 3 # Max retries on transient network errors (default: 3)
|
|
42
|
-
config.retry_interval = 0.1 # Initial delay in seconds (default: 0.1)
|
|
43
|
-
config.retry_backoff_factor = 2 # Multiplier for subsequent retries (default: 2)
|
|
44
|
-
config.retry_interval_randomness = 0.5 # Jitter factor (default: 0.5)
|
|
45
|
-
|
|
46
|
-
# Configure RubyLLM logger from centralized LoggerManager
|
|
47
|
-
config.log_level = LoggerManager.llm_log_level_symbol
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Configure RubyLLM's logger output destination
|
|
51
|
-
LoggerManager.configure_llm_logger
|
|
52
|
-
|
|
53
|
-
# Configure RubyLLM::MCP's logger early, before any MCP operations
|
|
54
|
-
# This ensures all MCP debug/info logs go to the configured log file
|
|
55
|
-
LoggerManager.configure_mcp_logger
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# lib/aia/adapter/tool_filter.rb
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require 'set'
|
|
5
|
-
|
|
6
|
-
module AIA
|
|
7
|
-
module Adapter
|
|
8
|
-
class ToolFilter
|
|
9
|
-
def self.filter_allowed(tools)
|
|
10
|
-
allowed = AIA.config.tools.allowed
|
|
11
|
-
return tools if allowed.nil? || allowed.empty?
|
|
12
|
-
|
|
13
|
-
allowed_list = Array(allowed).map(&:strip)
|
|
14
|
-
|
|
15
|
-
tools.select do |tool|
|
|
16
|
-
tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
|
|
17
|
-
allowed_list.any? { |allowed_pattern| tool_name.include?(allowed_pattern) }
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def self.filter_rejected(tools)
|
|
22
|
-
rejected = AIA.config.tools.rejected
|
|
23
|
-
return tools if rejected.nil? || rejected.empty?
|
|
24
|
-
|
|
25
|
-
rejected_list = Array(rejected).map(&:strip)
|
|
26
|
-
|
|
27
|
-
tools.reject do |tool|
|
|
28
|
-
tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name
|
|
29
|
-
rejected_list.any? { |rejected_pattern| tool_name.include?(rejected_pattern) }
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def self.detect_conflicts
|
|
34
|
-
allowed = AIA.config.tools.allowed
|
|
35
|
-
rejected = AIA.config.tools.rejected
|
|
36
|
-
return [] if allowed.nil? || allowed.empty? || rejected.nil? || rejected.empty?
|
|
37
|
-
|
|
38
|
-
allowed_list = Array(allowed).map(&:strip)
|
|
39
|
-
rejected_list = Array(rejected).map(&:strip)
|
|
40
|
-
|
|
41
|
-
conflicts = allowed_list & rejected_list
|
|
42
|
-
return [] if conflicts.empty?
|
|
43
|
-
|
|
44
|
-
logger = LoggerManager.aia_logger
|
|
45
|
-
conflicts.each do |pattern|
|
|
46
|
-
msg = "Tool pattern '#{pattern}' appears in both --allowed-tools and --rejected-tools. Rejected takes precedence."
|
|
47
|
-
logger.warn(msg)
|
|
48
|
-
warn "WARNING: #{msg}"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
conflicts
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def self.drop_duplicates(tools)
|
|
55
|
-
seen_names = Set.new
|
|
56
|
-
original_size = tools.size
|
|
57
|
-
|
|
58
|
-
logger = LoggerManager.aia_logger
|
|
59
|
-
logger.debug("Checking tools for duplicates", tool_count: original_size)
|
|
60
|
-
|
|
61
|
-
result = tools.select do |tool|
|
|
62
|
-
tool_name = tool.name
|
|
63
|
-
if seen_names.include?(tool_name)
|
|
64
|
-
logger.warn("Duplicate tool detected - keeping first occurrence only", tool: tool_name)
|
|
65
|
-
warn "WARNING: Duplicate tool name detected: '#{tool_name}'. Only the first occurrence will be used."
|
|
66
|
-
false
|
|
67
|
-
else
|
|
68
|
-
seen_names.add(tool_name)
|
|
69
|
-
true
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
removed_count = original_size - result.size
|
|
74
|
-
if removed_count > 0
|
|
75
|
-
logger.info("Removed duplicate tools", removed_count: removed_count, remaining_count: result.size)
|
|
76
|
-
warn "Removed #{removed_count} duplicate tools"
|
|
77
|
-
else
|
|
78
|
-
logger.debug("No duplicate tools found")
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
result
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
# lib/aia/adapter/tool_loader.rb
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
module AIA
|
|
5
|
-
module Adapter
|
|
6
|
-
class ToolLoader
|
|
7
|
-
def initialize(mcp_connector)
|
|
8
|
-
@mcp_connector = mcp_connector
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def load_tools
|
|
12
|
-
tools = []
|
|
13
|
-
|
|
14
|
-
tools += scan_local_tools
|
|
15
|
-
@mcp_connector.support_mcp(tools)
|
|
16
|
-
tools
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def scan_local_tools
|
|
20
|
-
tools = []
|
|
21
|
-
|
|
22
|
-
# First, load any required libraries specified in config
|
|
23
|
-
load_require_libs
|
|
24
|
-
|
|
25
|
-
# Then, load tool files from tools.paths
|
|
26
|
-
load_tool_files
|
|
27
|
-
|
|
28
|
-
# Now scan ObjectSpace for RubyLLM::Tool subclasses
|
|
29
|
-
tool_classes = ObjectSpace.each_object(Class).select do |klass|
|
|
30
|
-
next false unless klass < RubyLLM::Tool
|
|
31
|
-
|
|
32
|
-
# Filter out tools that can't be instantiated without arguments
|
|
33
|
-
# RubyLLM calls tool.new without args, so we must verify each tool works
|
|
34
|
-
begin
|
|
35
|
-
klass.new
|
|
36
|
-
true
|
|
37
|
-
rescue ArgumentError, LoadError, StandardError
|
|
38
|
-
# Skip tools that require arguments or have missing dependencies
|
|
39
|
-
false
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
tools + tool_classes
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def load_require_libs
|
|
49
|
-
require_libs = AIA.config.require_libs
|
|
50
|
-
return if require_libs.nil? || require_libs.empty?
|
|
51
|
-
|
|
52
|
-
require_libs.each do |lib|
|
|
53
|
-
begin
|
|
54
|
-
# Activate gem and add to load path (bypasses Bundler's restrictions)
|
|
55
|
-
GemActivator.activate_gem_for_require(lib)
|
|
56
|
-
|
|
57
|
-
require lib
|
|
58
|
-
|
|
59
|
-
# After requiring, trigger tool loading if the library supports it
|
|
60
|
-
# This handles gems like shared_tools that use Zeitwerk lazy loading
|
|
61
|
-
GemActivator.trigger_tool_loading(lib)
|
|
62
|
-
rescue LoadError => e
|
|
63
|
-
warn "Warning: Failed to require library '#{lib}': #{e.message}"
|
|
64
|
-
warn "Hint: Make sure the gem is installed: gem install #{lib}"
|
|
65
|
-
rescue StandardError => e
|
|
66
|
-
warn "Warning: Error in library '#{lib}': #{e.class} - #{e.message}"
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def load_tool_files
|
|
72
|
-
paths = AIA.config.tools&.paths
|
|
73
|
-
return if paths.nil? || paths.empty?
|
|
74
|
-
|
|
75
|
-
paths.each do |path|
|
|
76
|
-
expanded_path = File.expand_path(path)
|
|
77
|
-
if File.exist?(expanded_path)
|
|
78
|
-
begin
|
|
79
|
-
require expanded_path
|
|
80
|
-
rescue LoadError, StandardError => e
|
|
81
|
-
warn "Warning: Failed to load tool file '#{path}': #{e.message}"
|
|
82
|
-
end
|
|
83
|
-
else
|
|
84
|
-
warn "Warning: Tool file not found: #{path}"
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|