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
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_connection_manager.rb
|
|
4
|
+
#
|
|
5
|
+
# Single owner for MCP connection state. Handles connecting to configured
|
|
6
|
+
# MCP servers concurrently, tracking successes/failures, and injecting
|
|
7
|
+
# results into robots.
|
|
8
|
+
|
|
9
|
+
require 'timeout'
|
|
10
|
+
require 'tty-spinner'
|
|
11
|
+
|
|
12
|
+
module AIA
|
|
13
|
+
class MCPConnectionManager
|
|
14
|
+
# Maximum seconds to wait for a single MCP server to connect.
|
|
15
|
+
DEFAULT_TIMEOUT = 30
|
|
16
|
+
|
|
17
|
+
attr_reader :connected_clients, :connected_tools, :failed_servers
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@connected_clients = {}
|
|
21
|
+
@connected_tools = []
|
|
22
|
+
@failed_servers = []
|
|
23
|
+
@server_tool_counts = {}
|
|
24
|
+
@connected = false
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Connect all configured MCP servers concurrently.
|
|
29
|
+
# Each server gets its own spinner line and thread.
|
|
30
|
+
#
|
|
31
|
+
# @param servers [Array<Hash>] MCP server configurations
|
|
32
|
+
# @return [self]
|
|
33
|
+
def connect_all(servers)
|
|
34
|
+
return self unless servers.is_a?(Array) && servers.any?
|
|
35
|
+
|
|
36
|
+
reset_connection_state
|
|
37
|
+
logger = AIA::LoggerManager.mcp_logger
|
|
38
|
+
server_names = servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
|
|
39
|
+
logger.info("MCP initialization: connecting #{servers.size} server(s): #{server_names.join(', ')}")
|
|
40
|
+
|
|
41
|
+
multi = TTY::Spinner::Multi.new("[:spinner] Connecting MCP servers", format: :dots, output: $stderr)
|
|
42
|
+
threads = launch_server_threads(servers, multi, logger)
|
|
43
|
+
|
|
44
|
+
# The global cap must never be smaller than the slowest single server's
|
|
45
|
+
# timeout, or concurrent connections get killed before they can finish.
|
|
46
|
+
global_limit = [global_connection_timeout, *servers.map { |s| server_timeout(s) }].max
|
|
47
|
+
Timeout.timeout(global_limit) { threads.each(&:join) }
|
|
48
|
+
|
|
49
|
+
@connected = true
|
|
50
|
+
logger.info("MCP initialization complete: #{@connected_clients.size} connected, #{@failed_servers.size} failed")
|
|
51
|
+
self
|
|
52
|
+
rescue Timeout::Error
|
|
53
|
+
handle_global_timeout(servers, global_limit)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Inject connected MCP clients and tools into robot(s).
|
|
57
|
+
#
|
|
58
|
+
# @param robot [RobotLab::Robot, RobotLab::Network] the robot or network
|
|
59
|
+
# @return [self]
|
|
60
|
+
def inject_into(robot)
|
|
61
|
+
clients, tools = @mutex.synchronize { [@connected_clients.dup, @connected_tools.dup] }
|
|
62
|
+
|
|
63
|
+
targets = robot.crew
|
|
64
|
+
|
|
65
|
+
targets.each do |target|
|
|
66
|
+
target.inject_mcp!(clients: clients, tools: tools)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Update AIA config with connection results.
|
|
73
|
+
#
|
|
74
|
+
# @return [self]
|
|
75
|
+
def update_config
|
|
76
|
+
connected_keys, tool_counts, failed = @mutex.synchronize do
|
|
77
|
+
[@connected_clients.keys.dup, @server_tool_counts.dup, @failed_servers.dup]
|
|
78
|
+
end
|
|
79
|
+
AIA.config.connected_mcp_servers = connected_keys
|
|
80
|
+
AIA.config.mcp_server_tool_counts = tool_counts
|
|
81
|
+
AIA.config.failed_mcp_servers = failed
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Whether connect_all has been called.
|
|
86
|
+
def connected?
|
|
87
|
+
@connected
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether any tools were collected (from config servers or Ruby require).
|
|
91
|
+
def any_tools?
|
|
92
|
+
@mutex.synchronize { @connected_tools.any? }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Absorb MCP clients registered in RubyLLM::MCP (e.g., via --require loading
|
|
96
|
+
# shared_tools/mcp/* files) that are not already tracked by this manager.
|
|
97
|
+
# Starts any unstarted clients, extracts their tools, and merges them into
|
|
98
|
+
# the connected state so inject_into will include them in the robot.
|
|
99
|
+
#
|
|
100
|
+
# @return [self]
|
|
101
|
+
def absorb_ruby_llm_mcp_clients
|
|
102
|
+
return self unless defined?(RubyLLM::MCP)
|
|
103
|
+
|
|
104
|
+
logger = AIA::LoggerManager.mcp_logger
|
|
105
|
+
|
|
106
|
+
RubyLLM::MCP.clients.each do |name, client|
|
|
107
|
+
next if @connected_clients.key?(name)
|
|
108
|
+
|
|
109
|
+
begin
|
|
110
|
+
client.start unless client.alive?
|
|
111
|
+
|
|
112
|
+
if client.alive?
|
|
113
|
+
tools = client.tools rescue []
|
|
114
|
+
@mutex.synchronize do
|
|
115
|
+
@connected_clients[name] = client
|
|
116
|
+
@server_tool_counts[name] = tools.size
|
|
117
|
+
@connected_tools.concat(tools)
|
|
118
|
+
end
|
|
119
|
+
logger.info("MCP: absorbed RubyLLM::MCP client '#{name}' (#{tools.size} tools)")
|
|
120
|
+
else
|
|
121
|
+
logger.warn("MCP: RubyLLM::MCP client '#{name}' not alive, skipping")
|
|
122
|
+
end
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
logger.warn("MCP: error absorbing RubyLLM::MCP client '#{name}': #{e.message}")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
@connected = true unless RubyLLM::MCP.clients.empty?
|
|
129
|
+
self
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Find an MCP client by server name.
|
|
133
|
+
#
|
|
134
|
+
# @param name [String] server name
|
|
135
|
+
# @return [RobotLab::MCP::Client, nil]
|
|
136
|
+
def client(name)
|
|
137
|
+
@connected_clients[name]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Connected server names.
|
|
141
|
+
#
|
|
142
|
+
# @return [Array<String>]
|
|
143
|
+
def connected_server_names
|
|
144
|
+
@mutex.synchronize { @connected_clients.keys.dup }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Failed server names.
|
|
148
|
+
#
|
|
149
|
+
# @return [Array<String>]
|
|
150
|
+
def failed_server_names
|
|
151
|
+
@mutex.synchronize { @failed_servers.map { |f| f[:name] } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Close all connected MCP clients and reset connection state.
|
|
155
|
+
# Errors from individual client closes are silently rescued —
|
|
156
|
+
# best-effort cleanup on process exit.
|
|
157
|
+
def close_all
|
|
158
|
+
clients = @mutex.synchronize { @connected_clients.dup }
|
|
159
|
+
clients.each_value do |client|
|
|
160
|
+
client.close
|
|
161
|
+
rescue StandardError
|
|
162
|
+
# best-effort — client may already be closed
|
|
163
|
+
end
|
|
164
|
+
@mutex.synchronize do
|
|
165
|
+
@connected_clients = {}
|
|
166
|
+
@connected_tools = []
|
|
167
|
+
@server_tool_counts = {}
|
|
168
|
+
@connected = false
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def reset_connection_state
|
|
175
|
+
@connected_clients = {}
|
|
176
|
+
@connected_tools = []
|
|
177
|
+
@failed_servers = []
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def launch_server_threads(servers, multi, logger)
|
|
181
|
+
servers.map do |server_config|
|
|
182
|
+
name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
|
|
183
|
+
spinner = multi.register("[:spinner] #{name}")
|
|
184
|
+
Thread.new(server_config, name, spinner) { |cfg, srv_name, sp| connect_one(cfg, srv_name, sp, logger) }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def handle_global_timeout(servers, global_limit)
|
|
189
|
+
logger = AIA::LoggerManager.mcp_logger
|
|
190
|
+
logger.warn("MCP: Global connection timeout (#{global_limit}s) reached")
|
|
191
|
+
|
|
192
|
+
connected_names = @mutex.synchronize { @connected_clients.keys.to_set }
|
|
193
|
+
failed_names = @mutex.synchronize { @failed_servers.to_set { |f| f[:name] } }
|
|
194
|
+
|
|
195
|
+
servers.each do |server_config|
|
|
196
|
+
srv_name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
|
|
197
|
+
next if connected_names.include?(srv_name) || failed_names.include?(srv_name)
|
|
198
|
+
@mutex.synchronize { @failed_servers << { name: srv_name, error: "global connection timeout (#{global_limit}s)" } }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Read global MCP connection timeout from config.
|
|
203
|
+
# Falls back to 120s if config is unavailable.
|
|
204
|
+
def global_connection_timeout
|
|
205
|
+
AIA.config&.concurrency&.mcp_timeout.to_i.then { |v| v.positive? ? v : 120 }
|
|
206
|
+
rescue StandardError
|
|
207
|
+
120
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Connect a single MCP server, updating the spinner on completion.
|
|
211
|
+
# rubocop:disable Metrics/MethodLength
|
|
212
|
+
def connect_one(server_config, name, spinner, logger)
|
|
213
|
+
timeout = server_timeout(server_config)
|
|
214
|
+
spinner.auto_spin
|
|
215
|
+
|
|
216
|
+
logger.info("MCP: connecting to '#{name}'...")
|
|
217
|
+
# rubocop:disable Metrics/BlockLength
|
|
218
|
+
Timeout.timeout(timeout) do
|
|
219
|
+
client = RobotLab::MCP::Client.new(server_config)
|
|
220
|
+
client.connect
|
|
221
|
+
|
|
222
|
+
if client.connected?
|
|
223
|
+
tools = client.list_tools
|
|
224
|
+
built_tools = tools.map do |tool_def|
|
|
225
|
+
tool_name = tool_def[:name]
|
|
226
|
+
mcp_client = client
|
|
227
|
+
RobotLab::Tool.create(
|
|
228
|
+
name: tool_name,
|
|
229
|
+
description: tool_def[:description],
|
|
230
|
+
parameters: tool_def[:inputSchema],
|
|
231
|
+
mcp: name
|
|
232
|
+
) { |args| mcp_client.call_tool(tool_name, args) }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
@mutex.synchronize do
|
|
236
|
+
@connected_clients[name] = client
|
|
237
|
+
@server_tool_counts[name] = tools.size
|
|
238
|
+
add_tools_deduped(built_tools, name, logger)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
logger.info("MCP: '#{name}' connected (#{tools.size} tools)")
|
|
242
|
+
spinner.success("(#{tools.size} tools)")
|
|
243
|
+
else
|
|
244
|
+
@mutex.synchronize do
|
|
245
|
+
@failed_servers << { name: name, error: "connection failed" }
|
|
246
|
+
end
|
|
247
|
+
logger.warn("MCP: '#{name}' failed to connect")
|
|
248
|
+
spinner.error("(connection failed)")
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
# rubocop:enable Metrics/BlockLength
|
|
252
|
+
rescue Timeout::Error
|
|
253
|
+
@mutex.synchronize do
|
|
254
|
+
@failed_servers << { name: name, error: "timed out after #{timeout}s" }
|
|
255
|
+
end
|
|
256
|
+
logger.warn("MCP: '#{name}' timed out after #{timeout}s")
|
|
257
|
+
spinner.error("(timed out)")
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
@mutex.synchronize do
|
|
260
|
+
@failed_servers << { name: name, error: e.message }
|
|
261
|
+
end
|
|
262
|
+
logger.warn("MCP: '#{name}' error: #{e.message}")
|
|
263
|
+
spinner.error("(#{e.message})")
|
|
264
|
+
end
|
|
265
|
+
# rubocop:enable Metrics/MethodLength
|
|
266
|
+
|
|
267
|
+
# Add tools to @connected_tools, skipping any whose name is already present.
|
|
268
|
+
# Logs a warning for each duplicate so the user knows which server wins.
|
|
269
|
+
# MUST be called inside @mutex.synchronize.
|
|
270
|
+
#
|
|
271
|
+
# @param new_tools [Array] tools to add
|
|
272
|
+
# @param server_name [String] name of the server providing these tools (for warning)
|
|
273
|
+
# @param logger [Logger] MCP logger
|
|
274
|
+
def add_tools_deduped(new_tools, server_name, logger)
|
|
275
|
+
new_tools.each do |tool|
|
|
276
|
+
tool_name = tool.respond_to?(:name) ? tool.name : tool.to_s
|
|
277
|
+
existing = @connected_tools.find do |t|
|
|
278
|
+
(t.respond_to?(:name) ? t.name : t.to_s) == tool_name
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
if existing
|
|
282
|
+
logger.warn("MCP: Tool '#{tool_name}' already registered from another server. " \
|
|
283
|
+
"Skipping duplicate from '#{server_name}'.")
|
|
284
|
+
else
|
|
285
|
+
@connected_tools << tool
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Extract timeout in seconds from server config.
|
|
291
|
+
# Config values >= 1000 are treated as milliseconds.
|
|
292
|
+
def server_timeout(server_config)
|
|
293
|
+
raw = server_config.is_a?(Hash) ? server_config[:timeout] : nil
|
|
294
|
+
return DEFAULT_TIMEOUT if raw.nil?
|
|
295
|
+
|
|
296
|
+
seconds = raw.to_f
|
|
297
|
+
seconds /= 1000.0 if seconds >= 1000 # values >= 1000 are milliseconds
|
|
298
|
+
# Honor the configured per-server timeout. DEFAULT_TIMEOUT is only the
|
|
299
|
+
# fallback when no timeout is set (handled above) — it must NOT cap a
|
|
300
|
+
# larger configured value, or slow-starting servers (npx/uvx cold starts)
|
|
301
|
+
# get dropped and their tools never reach the robot or the tool filter.
|
|
302
|
+
seconds.positive? ? seconds : DEFAULT_TIMEOUT
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_discovery.rb
|
|
4
|
+
#
|
|
5
|
+
# Determines which MCP servers should be activated for a given prompt.
|
|
6
|
+
# Two strategies: explicit (user directive) or all configured (default).
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
class MCPDiscovery
|
|
10
|
+
def initialize; end
|
|
11
|
+
|
|
12
|
+
# Discover relevant MCP servers for the current prompt.
|
|
13
|
+
# Priority: explicit --mcp-use list > all configured servers.
|
|
14
|
+
# --mcp-skip is always applied last.
|
|
15
|
+
#
|
|
16
|
+
# @param config the AIA configuration
|
|
17
|
+
# @return [Array<Hash>] relevant MCP server configs
|
|
18
|
+
def discover(config)
|
|
19
|
+
return [] if config.flags&.no_mcp
|
|
20
|
+
|
|
21
|
+
servers = select_servers(config)
|
|
22
|
+
apply_skip_filter(servers, config)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def select_servers(config)
|
|
28
|
+
return select_by_names(config.mcp_servers || [], Array(config.mcp_use)) if Array(config.mcp_use).any?
|
|
29
|
+
|
|
30
|
+
config.mcp_servers || []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apply_skip_filter(servers, config)
|
|
34
|
+
skip_list = Array(config.mcp_skip)
|
|
35
|
+
return servers if skip_list.empty?
|
|
36
|
+
|
|
37
|
+
servers.reject { |s| skip_list.include?(AIA::Utility.server_name(s)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def select_by_names(all_servers, names)
|
|
41
|
+
all_servers.select { |s| names.include?(AIA::Utility.server_name(s)) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_grouper.rb
|
|
4
|
+
#
|
|
5
|
+
# Groups MCP servers into independent sets that can run concurrently.
|
|
6
|
+
# Servers with the same `group` value must run sequentially.
|
|
7
|
+
# Servers without a group are each independent.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class MCPGrouper
|
|
11
|
+
# Group MCP servers into independent sets.
|
|
12
|
+
#
|
|
13
|
+
# @param servers [Array<Hash>] MCP server configs
|
|
14
|
+
# @return [Array<Array<Hash>>] groups of servers that can run concurrently
|
|
15
|
+
def group(servers)
|
|
16
|
+
return [] if servers.nil? || servers.empty?
|
|
17
|
+
|
|
18
|
+
independent = []
|
|
19
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
20
|
+
|
|
21
|
+
servers.each do |server|
|
|
22
|
+
group_name = server[:group] || server["group"]
|
|
23
|
+
if group_name
|
|
24
|
+
groups[group_name] << server
|
|
25
|
+
else
|
|
26
|
+
independent << [server] # Each independent server is its own group
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
independent + groups.values
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_utility.rb
|
|
4
|
+
#
|
|
5
|
+
# Pure MCP state-query methods mixed into AIA::Utility via `class << self include`.
|
|
6
|
+
# No display or formatting concerns — those live in Utility directly.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
module MCPUtility
|
|
10
|
+
def mcp_servers?
|
|
11
|
+
effective_mcp_server_names.any?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def mcp_server_names
|
|
15
|
+
connected = AIA.config&.connected_mcp_servers
|
|
16
|
+
return connected unless connected.nil?
|
|
17
|
+
return RubyLLM::MCP.clients.keys if defined?(RubyLLM::MCP) && RubyLLM::MCP.clients.any?
|
|
18
|
+
effective_mcp_server_names
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def connected_mcp_servers?
|
|
22
|
+
mcp_server_names.any?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def failed_mcp_servers
|
|
26
|
+
AIA.config&.failed_mcp_servers || []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def effective_mcp_server_names
|
|
30
|
+
return [] if AIA.config&.flags&.no_mcp
|
|
31
|
+
servers = AIA.config&.mcp_servers || []
|
|
32
|
+
return [] if servers.empty?
|
|
33
|
+
|
|
34
|
+
names = servers.map { |s| server_name(s) }.compact
|
|
35
|
+
use_list = Array(AIA.config.mcp_use)
|
|
36
|
+
skip_list = Array(AIA.config.mcp_skip)
|
|
37
|
+
|
|
38
|
+
if use_list.any?
|
|
39
|
+
names.select { |n| use_list.include?(n) }
|
|
40
|
+
elsif skip_list.any?
|
|
41
|
+
names.reject { |n| skip_list.include?(n) }
|
|
42
|
+
else
|
|
43
|
+
names
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def server_name(s)
|
|
48
|
+
if s.is_a?(Hash)
|
|
49
|
+
s[:name] || s['name']
|
|
50
|
+
elsif s.respond_to?(:name)
|
|
51
|
+
s.name
|
|
52
|
+
else
|
|
53
|
+
s.to_s
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mention_router.rb
|
|
4
|
+
#
|
|
5
|
+
# Handles @mention routing in multi-model networks.
|
|
6
|
+
# Scans prompts for @name tokens, matches them to robots in the
|
|
7
|
+
# network, and sends the prompt only to mentioned robots.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class MentionRouter
|
|
11
|
+
include ContentExtractor
|
|
12
|
+
include HandlerProtocol
|
|
13
|
+
|
|
14
|
+
# Reserved @mention that broadcasts the message to every crew member.
|
|
15
|
+
BROADCAST_TOKEN = 'crew'
|
|
16
|
+
|
|
17
|
+
def initialize(ui_presenter:, tracker:, streaming_runner:)
|
|
18
|
+
@ui_presenter = ui_presenter
|
|
19
|
+
@tracker = tracker
|
|
20
|
+
@streaming_runner = streaming_runner
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Scan prompt for @mentions and route to matching robots.
|
|
24
|
+
# Returns true if mentions were handled, false otherwise.
|
|
25
|
+
#
|
|
26
|
+
# @param context [HandlerContext] — reads context.robot and context.prompt
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def handle(context) # rubocop:disable Naming/PredicateMethod
|
|
29
|
+
robot = context.robot
|
|
30
|
+
prompt = context.prompt
|
|
31
|
+
return false unless robot.network?
|
|
32
|
+
|
|
33
|
+
mention_tokens = prompt.scan(/@(\w+)/i).flatten
|
|
34
|
+
return false if mention_tokens.empty?
|
|
35
|
+
|
|
36
|
+
all_robots = robot.crew
|
|
37
|
+
matched = resolve_mentions(mention_tokens, all_robots)
|
|
38
|
+
|
|
39
|
+
if matched.empty?
|
|
40
|
+
report_unknown_mentions(mention_tokens, all_robots)
|
|
41
|
+
return true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
clean_prompt = strip_addressing_prefix(prompt)
|
|
45
|
+
|
|
46
|
+
report_unknown_mentions(mention_tokens, all_robots)
|
|
47
|
+
run_mentioned_robots(matched, clean_prompt, concurrent: leading_address_only?(prompt))
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Remove only the leading run of @mentions (the addressing prefix), so
|
|
54
|
+
# "@alice summarize this" becomes "summarize this" while @mentions inside the
|
|
55
|
+
# message body — "hello @hemie have you met @joker" — are preserved as
|
|
56
|
+
# content and reach the routed robots intact.
|
|
57
|
+
def strip_addressing_prefix(prompt)
|
|
58
|
+
prompt.sub(/\A(?:\s*@\w+)+\s*/i, '').strip
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# True when every @mention is part of the leading address — i.e. nothing but
|
|
62
|
+
# @mentions before the message — so it's a broadcast to its addressees and
|
|
63
|
+
# runs concurrently. A @mention woven into the body makes it sequential.
|
|
64
|
+
def leading_address_only?(prompt)
|
|
65
|
+
!strip_addressing_prefix(prompt).match?(/@\w+/)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Resolve @mention tokens to robots. The reserved @crew token broadcasts to
|
|
69
|
+
# the entire crew; otherwise each token is matched to a robot by name.
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<Robot>]
|
|
72
|
+
def resolve_mentions(tokens, all_robots)
|
|
73
|
+
return all_robots if tokens.any? { |token| token.casecmp?(BROADCAST_TOKEN) }
|
|
74
|
+
|
|
75
|
+
tokens.filter_map { |token| all_robots.find { |r| r.name.casecmp?(token) } }.uniq(&:name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def report_unknown_mentions(mention_tokens, all_robots)
|
|
79
|
+
known_names = all_robots.map { |r| r.name.downcase }
|
|
80
|
+
unknown = mention_tokens.reject { |t| t.casecmp?(BROADCAST_TOKEN) || known_names.include?(t.downcase) }
|
|
81
|
+
return unless unknown.any?
|
|
82
|
+
|
|
83
|
+
available = all_robots.map(&:name).join(', ')
|
|
84
|
+
unknown.each do |name|
|
|
85
|
+
@ui_presenter.display_info("Unknown robot: @#{name} (available: #{available})")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_mentioned_robots(robots, prompt, concurrent:)
|
|
90
|
+
if robots.size == 1
|
|
91
|
+
run_streamed(robots.first, prompt)
|
|
92
|
+
elsif concurrent
|
|
93
|
+
run_concurrently(robots, prompt)
|
|
94
|
+
else
|
|
95
|
+
run_sequentially(robots, prompt)
|
|
96
|
+
end
|
|
97
|
+
@ui_presenter.display_separator
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Robots mentioned in the body run one at a time, each streaming its reply.
|
|
101
|
+
# After each reply, it is injected into every *other* member's conversation
|
|
102
|
+
# so later robots build on it and earlier ones carry it forward — the
|
|
103
|
+
# sequential pipeline becomes a shared, accumulating conversation.
|
|
104
|
+
def run_sequentially(robots, prompt)
|
|
105
|
+
robots.each do |bot|
|
|
106
|
+
content = run_streamed(bot, prompt)
|
|
107
|
+
share_with_peers(robots, bot, content) if content
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Add one robot's reply to every other pipeline member's chat history.
|
|
112
|
+
def share_with_peers(robots, author, content)
|
|
113
|
+
note = "[#{author.name}]: #{content}"
|
|
114
|
+
(robots - [author]).each do |peer|
|
|
115
|
+
next unless peer.respond_to?(:chat) && peer.chat
|
|
116
|
+
|
|
117
|
+
peer.chat.add_message({ role: :user, content: note })
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A single addressed robot streams its reply token-by-token.
|
|
122
|
+
def run_streamed(bot, prompt)
|
|
123
|
+
result, streamed_content, elapsed = @streaming_runner.run(
|
|
124
|
+
bot, prompt,
|
|
125
|
+
header: "\nAI (#{bot.name}):\n ",
|
|
126
|
+
spinner_message: "#{bot.name} processing..."
|
|
127
|
+
)
|
|
128
|
+
content = streamed_content || extract_content(result)
|
|
129
|
+
|
|
130
|
+
if streamed_content
|
|
131
|
+
puts
|
|
132
|
+
@ui_presenter.display_info("(#{format_duration(elapsed)})")
|
|
133
|
+
else
|
|
134
|
+
@ui_presenter.display_ai_response(reply_block(bot, content, elapsed))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
finalize_reply(bot, prompt, result, content, elapsed)
|
|
138
|
+
content
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
@ui_presenter.display_info("Error from #{bot.name}: #{e.class}: #{e.message}")
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A broadcast to multiple robots runs them concurrently — no token streaming,
|
|
145
|
+
# which would interleave on one console. Each robot runs in its own thread and
|
|
146
|
+
# pushes its result to a queue; the main thread renders each reply as it lands
|
|
147
|
+
# (finish order), so wall-clock is the slowest robot, not the sum. Rendering
|
|
148
|
+
# stays single-threaded, so display/tracking are never touched concurrently.
|
|
149
|
+
def run_concurrently(robots, prompt)
|
|
150
|
+
@ui_presenter.display_info("Broadcasting to #{robots.size} robots concurrently…")
|
|
151
|
+
|
|
152
|
+
done = Queue.new
|
|
153
|
+
robots.each { |bot| Thread.new { done << run_member(bot, prompt) } }
|
|
154
|
+
robots.size.times { render_member(prompt, *done.pop) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Run one crew member to completion (off the streaming path), in a worker
|
|
158
|
+
# thread. Returns [bot, result, elapsed, error] — no shared state touched.
|
|
159
|
+
def run_member(bot, prompt)
|
|
160
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
result = bot.run(prompt, mcp: :inherit, tools: :inherit)
|
|
162
|
+
[bot, result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, nil]
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
[bot, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, e]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Render one crew member's reply on the main thread.
|
|
168
|
+
def render_member(prompt, bot, result, elapsed, error)
|
|
169
|
+
if error
|
|
170
|
+
@ui_presenter.display_info("Error from #{bot.name}: #{error.class}: #{error.message}")
|
|
171
|
+
return
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
content = extract_content(result)
|
|
175
|
+
@ui_presenter.display_ai_response(reply_block(bot, content, elapsed))
|
|
176
|
+
finalize_reply(bot, prompt, result, content, elapsed)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def reply_block(bot, content, elapsed)
|
|
180
|
+
"**#{bot.name}** [#{bot.model || 'unknown'}] (#{format_duration(elapsed)}):\n#{content}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def finalize_reply(bot, prompt, result, content, elapsed)
|
|
184
|
+
@tracker.record_turn(model: bot.model || 'unknown', input: prompt, result: result, elapsed: elapsed)
|
|
185
|
+
output_to_file(content)
|
|
186
|
+
display_metrics(result, elapsed: elapsed)
|
|
187
|
+
speak(content)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def display_metrics(result, elapsed: nil)
|
|
191
|
+
return unless AIA.config.flags.tokens
|
|
192
|
+
|
|
193
|
+
raw = result.respond_to?(:raw) ? result.raw : nil
|
|
194
|
+
return unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
|
|
195
|
+
|
|
196
|
+
model_id = extract_model_id(raw)
|
|
197
|
+
model_id ||= result.respond_to?(:robot_name) ? result.robot_name : "unknown"
|
|
198
|
+
metrics = {
|
|
199
|
+
model_id: model_id,
|
|
200
|
+
input_tokens: raw.input_tokens,
|
|
201
|
+
output_tokens: raw.output_tokens,
|
|
202
|
+
elapsed: elapsed
|
|
203
|
+
}
|
|
204
|
+
@ui_presenter.display_token_metrics(metrics)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def extract_model_id(message)
|
|
208
|
+
return message.model_id if message.respond_to?(:model_id) && message.model_id
|
|
209
|
+
return message.model if message.respond_to?(:model) && message.model
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def speak(content)
|
|
214
|
+
return unless AIA.speak?
|
|
215
|
+
|
|
216
|
+
audio = AIA.config.audio
|
|
217
|
+
command = audio.speak_command || 'say'
|
|
218
|
+
env = {}
|
|
219
|
+
env['SPEECH_MODEL'] = audio.speech_model if audio.speech_model
|
|
220
|
+
|
|
221
|
+
if command == 'say'
|
|
222
|
+
run_with_spinner("Speaking...") do
|
|
223
|
+
if audio.voice && !audio.voice.to_s.strip.empty?
|
|
224
|
+
system(env, command, '-v', audio.voice, content.to_s)
|
|
225
|
+
else
|
|
226
|
+
system(env, command, content.to_s)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
require 'tempfile'
|
|
231
|
+
tmpfile = Tempfile.new(['aia-tts-', '.mp3'])
|
|
232
|
+
tmpfile.close
|
|
233
|
+
begin
|
|
234
|
+
run_with_spinner("Converting to audio...") do
|
|
235
|
+
system(env, command, content.to_s, tmpfile.path)
|
|
236
|
+
end
|
|
237
|
+
if File.size?(tmpfile.path)
|
|
238
|
+
run_with_spinner("Speaking...") do
|
|
239
|
+
system('afplay', tmpfile.path)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
ensure
|
|
243
|
+
tmpfile.unlink
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
$stderr.puts "Warning: Speech failed: #{e.message}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def run_with_spinner(message)
|
|
251
|
+
spinner = TTY::Spinner.new("[:spinner] #{message}", format: :bouncing_ball, output: $stderr)
|
|
252
|
+
spinner.auto_spin
|
|
253
|
+
begin
|
|
254
|
+
yield
|
|
255
|
+
ensure
|
|
256
|
+
spinner.stop
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|