aia 2.0.0.0.pre.alpha → 2.0.0.0.pre.beta2
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 +3 -223
- data/.reek.yml +160 -0
- data/.rubocop_strict.yml +15 -0
- data/.version +1 -1
- data/CHANGELOG.md +10 -5
- data/README.md +1 -1
- data/Rakefile +0 -113
- data/_typos.toml +10 -0
- data/aia.gemspec +92 -0
- data/config/aia.yml +13 -0
- data/lib/aia/chat_loop.rb +8 -52
- data/lib/aia/config/cli_parser.rb +66 -57
- data/lib/aia/config/mcp_parser.rb +27 -19
- data/lib/aia/config/validator.rb +83 -94
- data/lib/aia/config.rb +3 -2
- data/lib/aia/content_extractor.rb +2 -0
- data/lib/aia/cost_calculator.rb +1 -0
- data/lib/aia/debate_handler.rb +28 -20
- data/lib/aia/delegate_handler.rb +7 -3
- data/lib/aia/directive.rb +10 -8
- data/lib/aia/directives/configuration_directives.rb +115 -101
- data/lib/aia/directives/context_directives.rb +31 -28
- data/lib/aia/directives/execution_directives.rb +18 -13
- data/lib/aia/directives/model_directives.rb +134 -154
- data/lib/aia/directives/trakflow_directives.rb +27 -9
- data/lib/aia/directives/utility_directives.rb +71 -71
- data/lib/aia/directives/web_and_file_directives.rb +63 -50
- data/lib/aia/layered_orchestrator.rb +66 -43
- data/lib/aia/logger.rb +21 -21
- data/lib/aia/mcp_connection_manager.rb +50 -40
- data/lib/aia/mcp_server_config.rb +30 -0
- data/lib/aia/mcp_utility.rb +7 -4
- data/lib/aia/mention_router.rb +7 -50
- data/lib/aia/network_builder.rb +10 -5
- data/lib/aia/network_memory_manager.rb +6 -6
- data/lib/aia/pipeline_orchestrator.rb +20 -10
- data/lib/aia/plugin_monitor.rb +9 -6
- data/lib/aia/prompt_decomposer.rb +5 -3
- data/lib/aia/prompt_handler.rb +43 -49
- data/lib/aia/robot_factory.rb +12 -8
- data/lib/aia/robot_namer.rb +2 -8
- data/lib/aia/session.rb +7 -3
- data/lib/aia/session_tracker.rb +39 -41
- data/lib/aia/similarity_scorer.rb +5 -3
- data/lib/aia/special_mode_handler.rb +52 -32
- data/lib/aia/speech.rb +67 -0
- data/lib/aia/startup_coordinator.rb +1 -0
- data/lib/aia/streaming_runner.rb +6 -3
- data/lib/aia/system_prompt_assembler.rb +11 -7
- data/lib/aia/task_coordinator.rb +10 -5
- data/lib/aia/timing.rb +15 -0
- data/lib/aia/tool_filter.rb +1 -0
- data/lib/aia/tool_filter_strategy.rb +28 -25
- data/lib/aia/tool_introspection.rb +17 -0
- data/lib/aia/tool_loader.rb +12 -6
- data/lib/aia/tools/task_board_tool.rb +1 -0
- data/lib/aia/trakflow_bridge.rb +5 -3
- data/lib/aia/turn_state.rb +1 -0
- data/lib/aia/ui_presenter.rb +67 -59
- data/lib/aia/utility.rb +5 -3
- data/lib/aia/variable_input_collector.rb +1 -0
- data/lib/aia/verification_network.rb +1 -2
- data/lib/aia.rb +16 -0
- metadata +15 -9
- data/.quality/flay_baseline.txt +0 -1
- data/.quality/flog_baseline.txt +0 -29
- data/.quality/reek_baseline.txt +0 -80
|
@@ -10,29 +10,34 @@ module AIA
|
|
|
10
10
|
PUREMD_API_KEY = ENV.fetch('PUREMD_API_KEY', nil)
|
|
11
11
|
|
|
12
12
|
desc "Fetch and include content from a webpage"
|
|
13
|
+
# :reek:TooManyStatements -- guard, fetch via pure.md, then success/error reporting in one flow
|
|
13
14
|
def webpage(args, _context_manager = nil)
|
|
14
15
|
if PUREMD_API_KEY.nil?
|
|
15
|
-
'ERROR: PUREMD_API_KEY is required in order to include a webpage'
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
msg = 'ERROR: PUREMD_API_KEY is required in order to include a webpage'
|
|
17
|
+
AIA::LoggerManager.aia_logger.error(msg)
|
|
18
|
+
puts msg
|
|
19
|
+
return nil
|
|
20
|
+
end
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
end
|
|
22
|
+
url = args.shift.to_s.strip
|
|
23
|
+
puremd_url = "https://pure.md/#{url}"
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
else
|
|
27
|
-
"Error: Status was #{response.status}\n#{ap response}"
|
|
28
|
-
end
|
|
25
|
+
response = Faraday.get(puremd_url) do |req|
|
|
26
|
+
req.headers['x-puremd-api-token'] = PUREMD_API_KEY
|
|
29
27
|
end
|
|
28
|
+
|
|
29
|
+
return response.body if response.status == 200
|
|
30
|
+
|
|
31
|
+
msg = "Error: Fetching #{url} returned status #{response.status}"
|
|
32
|
+
AIA::LoggerManager.aia_logger.error(msg)
|
|
33
|
+
puts msg
|
|
34
|
+
nil
|
|
30
35
|
end
|
|
31
36
|
alias website webpage
|
|
32
37
|
alias web webpage
|
|
33
38
|
|
|
34
39
|
desc "List available AIA skills"
|
|
35
|
-
#
|
|
40
|
+
# :reek:TooManyStatements -- sequential listing: directory guard, term filtering, per-skill output
|
|
36
41
|
def skills(args = [], _context_manager = nil)
|
|
37
42
|
dir = aia_skills_dir
|
|
38
43
|
unless Dir.exist?(dir)
|
|
@@ -41,18 +46,7 @@ module AIA
|
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
positive_terms, negative_terms = parse_search_terms(Array(args))
|
|
44
|
-
|
|
45
|
-
skill_dirs = Dir.children(dir).select do |e|
|
|
46
|
-
File.directory?(File.join(dir, e)) &&
|
|
47
|
-
File.exist?(File.join(dir, e, 'SKILL.md'))
|
|
48
|
-
end
|
|
49
|
-
entries = skill_dirs.select do |e|
|
|
50
|
-
next true if positive_terms.empty? && negative_terms.empty?
|
|
51
|
-
text = read_front_matter_text(File.join(dir, e, 'SKILL.md'))
|
|
52
|
-
# text is a String; Array#intersect? would raise TypeError
|
|
53
|
-
positive_terms.all? { |t| text.include?(t) } &&
|
|
54
|
-
negative_terms.none? { |t| text.include?(t) }
|
|
55
|
-
end.sort
|
|
49
|
+
entries = matching_skill_ids(dir, positive_terms, negative_terms)
|
|
56
50
|
|
|
57
51
|
if entries.empty?
|
|
58
52
|
all_terms = positive_terms + negative_terms
|
|
@@ -61,38 +55,23 @@ module AIA
|
|
|
61
55
|
end
|
|
62
56
|
|
|
63
57
|
wrap_width = terminal_width - 2
|
|
64
|
-
|
|
65
|
-
entries.each do |skill_id|
|
|
66
|
-
fm = parse_skill_front_matter(File.join(dir, skill_id, 'SKILL.md'))
|
|
67
|
-
name = fm['name'] || ''
|
|
68
|
-
description = fm['description'] || '(no description)'
|
|
69
|
-
puts "#{skill_id}: #{name}"
|
|
70
|
-
puts word_wrap(description, width: wrap_width, indent: ' ')
|
|
71
|
-
puts
|
|
72
|
-
end
|
|
58
|
+
entries.each { |skill_id| print_skill_entry(dir, skill_id, wrap_width) }
|
|
73
59
|
|
|
74
60
|
nil
|
|
75
61
|
end
|
|
76
|
-
# rubocop:enable Metrics/AbcSize
|
|
77
62
|
|
|
78
63
|
desc "Include an AIA skill from the configured skills directory"
|
|
79
64
|
def skill(args = [], _context_manager = nil)
|
|
80
65
|
args = Array(args)
|
|
81
66
|
skill_name = args.first&.strip
|
|
82
67
|
if skill_name.nil? || skill_name.empty?
|
|
83
|
-
|
|
84
|
-
AIA::LoggerManager.aia_logger.error(msg)
|
|
85
|
-
puts msg
|
|
86
|
-
return nil
|
|
68
|
+
return report_skill_error("Error: /skill requires a skill name. Use /skills to list available skills.")
|
|
87
69
|
end
|
|
88
70
|
|
|
89
71
|
dir = aia_skills_dir
|
|
90
72
|
skill_dir = find_skill_dir(skill_name, dir)
|
|
91
73
|
unless skill_dir
|
|
92
|
-
|
|
93
|
-
AIA::LoggerManager.aia_logger.error(msg)
|
|
94
|
-
puts msg
|
|
95
|
-
return nil
|
|
74
|
+
return report_skill_error("Error: No skill matching '#{skill_name}' found in #{dir}. Use /skills to list available skills.")
|
|
96
75
|
end
|
|
97
76
|
|
|
98
77
|
return File.read(skill_dir) if File.file?(skill_dir)
|
|
@@ -104,9 +83,7 @@ module AIA
|
|
|
104
83
|
else
|
|
105
84
|
"Error: Skill '#{File.basename(skill_dir)}' has no SKILL.md in #{dir}."
|
|
106
85
|
end
|
|
107
|
-
|
|
108
|
-
puts msg
|
|
109
|
-
return nil
|
|
86
|
+
return report_skill_error(msg)
|
|
110
87
|
end
|
|
111
88
|
|
|
112
89
|
File.read(skill_path)
|
|
@@ -117,17 +94,53 @@ module AIA
|
|
|
117
94
|
content = Clipboard.paste
|
|
118
95
|
content.to_s
|
|
119
96
|
rescue StandardError => e
|
|
120
|
-
"Error: Unable to paste from clipboard - #{e.message}"
|
|
97
|
+
msg = "Error: Unable to paste from clipboard - #{e.message}"
|
|
98
|
+
AIA::LoggerManager.aia_logger.error(msg)
|
|
99
|
+
puts msg
|
|
100
|
+
nil
|
|
121
101
|
end
|
|
122
102
|
alias clipboard paste
|
|
123
103
|
|
|
124
104
|
private
|
|
125
105
|
|
|
106
|
+
# Skill directories under dir with a SKILL.md whose front matter passes
|
|
107
|
+
# the positive/negative term filters, sorted.
|
|
108
|
+
def matching_skill_ids(dir, positive_terms, negative_terms)
|
|
109
|
+
skill_dirs = Dir.children(dir).select do |e|
|
|
110
|
+
File.directory?(File.join(dir, e)) &&
|
|
111
|
+
File.exist?(File.join(dir, e, 'SKILL.md'))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
skill_dirs.select do |e|
|
|
115
|
+
next true if positive_terms.empty? && negative_terms.empty?
|
|
116
|
+
text = read_front_matter_text(File.join(dir, e, 'SKILL.md'))
|
|
117
|
+
# text is a String; Array#intersect? would raise TypeError
|
|
118
|
+
positive_terms.all? { |t| text.include?(t) } &&
|
|
119
|
+
negative_terms.none? { |t| text.include?(t) }
|
|
120
|
+
end.sort
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_skill_entry(dir, skill_id, wrap_width)
|
|
124
|
+
fm = parse_skill_front_matter(File.join(dir, skill_id, 'SKILL.md'))
|
|
125
|
+
name = fm['name'] || ''
|
|
126
|
+
description = fm['description'] || '(no description)'
|
|
127
|
+
puts "#{skill_id}: #{name}"
|
|
128
|
+
puts word_wrap(description, width: wrap_width, indent: ' ')
|
|
129
|
+
puts
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Log and print a /skill error message; returns nil so callers can
|
|
133
|
+
# `return report_skill_error(msg)` from any guard.
|
|
134
|
+
def report_skill_error(msg)
|
|
135
|
+
AIA::LoggerManager.aia_logger.error(msg)
|
|
136
|
+
puts msg
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
126
140
|
# Resolve the AIA skills directory from config, env vars, or defaults.
|
|
127
141
|
def aia_skills_dir
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
end
|
|
142
|
+
skills = AIA.respond_to?(:config) ? AIA.config&.skills : nil
|
|
143
|
+
return skills.dir if skills.respond_to?(:dir) && skills.dir
|
|
131
144
|
|
|
132
145
|
ENV.fetch('AIA_SKILLS__DIR', File.expand_path('~/.prompts/skills'))
|
|
133
146
|
end
|
|
@@ -34,6 +34,12 @@ module AIA
|
|
|
34
34
|
MAX_LAYERS = 5
|
|
35
35
|
MAX_TASKS_PER_LAYER = 4
|
|
36
36
|
|
|
37
|
+
BUILD_BANNER = <<~BANNER
|
|
38
|
+
╔══════════════════════════════════════════════════════════╗
|
|
39
|
+
║ LAYERED ORCHESTRATION — 3-TIER BUILD ║
|
|
40
|
+
╚══════════════════════════════════════════════════════════╝
|
|
41
|
+
BANNER
|
|
42
|
+
|
|
37
43
|
# Tier 1 prompt: decompose requirements into layers
|
|
38
44
|
# Deliberately short and directive to minimise qwen3 think-block noise.
|
|
39
45
|
LAYER_DECOMPOSE_PROMPT = <<~PROMPT
|
|
@@ -124,19 +130,14 @@ module AIA
|
|
|
124
130
|
#
|
|
125
131
|
# @param context [HandlerContext] — reads context.prompt as requirements text
|
|
126
132
|
# @return [String, nil] final synthesis or nil on failure
|
|
127
|
-
#
|
|
133
|
+
# :reek:TooManyStatements -- single orchestration script (banner, tier 1-3 waves, synthesis, report); the narrative order is the value
|
|
134
|
+
# :reek:DuplicateMethodCall -- say("") prints deliberate blank separator lines between build phases; not a hoistable value
|
|
128
135
|
def handle(context)
|
|
129
136
|
requirements = context.prompt
|
|
130
137
|
primary = @robot.chief
|
|
131
138
|
|
|
132
139
|
FileUtils.mkdir_p(@build_dir)
|
|
133
|
-
|
|
134
|
-
say("╔══════════════════════════════════════════════════════════╗")
|
|
135
|
-
say("║ LAYERED ORCHESTRATION — 3-TIER BUILD ║")
|
|
136
|
-
say("╚══════════════════════════════════════════════════════════╝")
|
|
137
|
-
say("Requirements: #{requirements.lines.first.strip}")
|
|
138
|
-
say("Build output: #{@build_dir}")
|
|
139
|
-
say("")
|
|
140
|
+
print_build_banner(requirements)
|
|
140
141
|
|
|
141
142
|
# Tier 1: Tobor decomposes requirements into layers
|
|
142
143
|
layers = decompose_to_layers(primary, requirements)
|
|
@@ -173,23 +174,38 @@ module AIA
|
|
|
173
174
|
save_final_report(final_text)
|
|
174
175
|
say("Build complete: #{@build_dir}")
|
|
175
176
|
|
|
177
|
+
record_final_turn(requirements, final_text)
|
|
178
|
+
final_text
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
report_orchestration_error(e)
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def print_build_banner(requirements)
|
|
187
|
+
say("")
|
|
188
|
+
BUILD_BANNER.each_line { |line| say(line.chomp) }
|
|
189
|
+
say("Requirements: #{requirements.lines.first.strip}")
|
|
190
|
+
say("Build output: #{@build_dir}")
|
|
191
|
+
say("")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def record_final_turn(requirements, final_text)
|
|
176
195
|
@tracker.record_turn(
|
|
177
196
|
model: AIA.config.models.first.name,
|
|
178
197
|
input: requirements,
|
|
179
198
|
result: final_text
|
|
180
199
|
)
|
|
181
|
-
|
|
182
|
-
final_text
|
|
183
|
-
rescue StandardError => e
|
|
184
|
-
say("✗ Orchestration error: #{e.class}: #{e.message}")
|
|
185
|
-
e.backtrace&.first(5)&.each { |line| say(" #{line}") }
|
|
186
|
-
nil
|
|
187
200
|
end
|
|
188
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
189
201
|
|
|
190
|
-
|
|
202
|
+
def report_orchestration_error(error)
|
|
203
|
+
say("✗ Orchestration error: #{error.class}: #{error.message}")
|
|
204
|
+
error.backtrace&.first(5)&.each { |line| say(" #{line}") }
|
|
205
|
+
end
|
|
191
206
|
|
|
192
207
|
# Tier 1: use a probe robot to decompose requirements into layer specs
|
|
208
|
+
# :reek:TooManyStatements -- probe run plus parse-failure diagnostics and rescue reporting
|
|
193
209
|
def decompose_to_layers(robot, requirements)
|
|
194
210
|
say("Tier 1 ▶ #{robot.name} decomposing requirements into layers...")
|
|
195
211
|
probe = build_probe(robot.name + "-layer-probe")
|
|
@@ -227,6 +243,7 @@ module AIA
|
|
|
227
243
|
#
|
|
228
244
|
# @param layers [Array<Hash>] layer specs from Tier 1
|
|
229
245
|
# @return [Hash{ String => Array<Hash> }] layer_name => task list ([] on failure)
|
|
246
|
+
# :reek:TooManyStatements -- per-layer async fan-out with per-layer error capture; the barrier plumbing is inherent
|
|
230
247
|
def run_leads_wave(layers)
|
|
231
248
|
results = {}
|
|
232
249
|
|
|
@@ -234,19 +251,21 @@ module AIA
|
|
|
234
251
|
barrier = Async::Barrier.new
|
|
235
252
|
layers.each do |layer|
|
|
236
253
|
barrier.async do
|
|
237
|
-
|
|
254
|
+
name = layer['name']
|
|
255
|
+
title = layer['title']
|
|
256
|
+
probe = build_probe("#{name}-lead")
|
|
238
257
|
prompt = TASK_DECOMPOSE_PROMPT % {
|
|
239
|
-
layer_title:
|
|
258
|
+
layer_title: title,
|
|
240
259
|
requirements: layer['requirements'].to_s,
|
|
241
260
|
max_tasks: MAX_TASKS_PER_LAYER
|
|
242
261
|
}
|
|
243
262
|
result = probe.run(prompt, mcp: :none, tools: :none)
|
|
244
263
|
tasks = parse_json_array(extract_content(result)).first(MAX_TASKS_PER_LAYER)
|
|
245
|
-
say(" ✓ #{
|
|
246
|
-
results[
|
|
264
|
+
say(" ✓ #{title} lead: #{tasks.size} task(s)")
|
|
265
|
+
results[name] = tasks
|
|
247
266
|
rescue => e
|
|
248
|
-
say(" ✗ #{
|
|
249
|
-
results[
|
|
267
|
+
say(" ✗ #{title} lead failed: #{e.message}")
|
|
268
|
+
results[name] = []
|
|
250
269
|
end
|
|
251
270
|
end
|
|
252
271
|
barrier.wait
|
|
@@ -261,6 +280,7 @@ module AIA
|
|
|
261
280
|
#
|
|
262
281
|
# @param layer_task_map [Hash{ String => Array<Hash> }] output of run_leads_wave
|
|
263
282
|
# @return [Hash{ String => Hash }] "layer_name|artifact_path" => result hash
|
|
283
|
+
# :reek:TooManyStatements -- per-task async fan-out with success/failure result recording; the barrier plumbing is inherent
|
|
264
284
|
def run_specialists_wave(layer_task_map)
|
|
265
285
|
jobs = layer_task_map.flat_map do |layer_name, tasks|
|
|
266
286
|
tasks.map { |task| { layer_name: layer_name, task: task } }
|
|
@@ -273,31 +293,32 @@ module AIA
|
|
|
273
293
|
barrier = Async::Barrier.new
|
|
274
294
|
jobs.each do |job|
|
|
275
295
|
barrier.async do
|
|
276
|
-
task
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
296
|
+
task = job[:task]
|
|
297
|
+
artifact = task['artifact']
|
|
298
|
+
title = task['title']
|
|
299
|
+
specialist = task['specialist']
|
|
300
|
+
key = "#{job[:layer_name]}|#{artifact}"
|
|
301
|
+
probe = build_probe("#{specialist}-specialist")
|
|
302
|
+
|
|
303
|
+
full_prompt = "Artifact to produce: #{artifact}\n\n#{task['prompt']}"
|
|
281
304
|
result = probe.run(full_prompt, mcp: :none, tools: :none)
|
|
282
305
|
output = extract_content(result)
|
|
283
306
|
|
|
284
|
-
save_artifact(
|
|
285
|
-
say(" ✓ #{
|
|
307
|
+
save_artifact(artifact, output)
|
|
308
|
+
say(" ✓ #{artifact}")
|
|
286
309
|
|
|
287
310
|
results[key] = {
|
|
288
|
-
task:
|
|
289
|
-
specialist:
|
|
290
|
-
artifact:
|
|
311
|
+
task: title,
|
|
312
|
+
specialist: specialist,
|
|
313
|
+
artifact: artifact,
|
|
291
314
|
output: output
|
|
292
315
|
}
|
|
293
316
|
rescue => e
|
|
294
|
-
|
|
295
|
-
key = "#{job[:layer_name]}|#{task['artifact']}"
|
|
296
|
-
say(" ✗ #{task['artifact']} failed: #{e.message}")
|
|
317
|
+
say(" ✗ #{artifact} failed: #{e.message}")
|
|
297
318
|
results[key] = {
|
|
298
|
-
task:
|
|
299
|
-
specialist:
|
|
300
|
-
artifact:
|
|
319
|
+
task: title,
|
|
320
|
+
specialist: specialist,
|
|
321
|
+
artifact: artifact,
|
|
301
322
|
output: "[FAILED: #{e.message}]"
|
|
302
323
|
}
|
|
303
324
|
end
|
|
@@ -317,9 +338,11 @@ module AIA
|
|
|
317
338
|
# @return [Array<Hash>]
|
|
318
339
|
def build_layer_results(layers, layer_task_map, specialist_results)
|
|
319
340
|
layers.map do |layer|
|
|
320
|
-
|
|
341
|
+
name = layer['name']
|
|
342
|
+
title = layer['title']
|
|
343
|
+
tasks_for_layer = layer_task_map[name] || []
|
|
321
344
|
task_results = tasks_for_layer.map do |task|
|
|
322
|
-
key = "#{
|
|
345
|
+
key = "#{name}|#{task['artifact']}"
|
|
323
346
|
specialist_results[key] || {
|
|
324
347
|
task: task['title'],
|
|
325
348
|
specialist: task['specialist'],
|
|
@@ -328,9 +351,9 @@ module AIA
|
|
|
328
351
|
}
|
|
329
352
|
end
|
|
330
353
|
|
|
331
|
-
summary = synthesize_layer_from_results(
|
|
332
|
-
say(" ✓ #{
|
|
333
|
-
{ layer:
|
|
354
|
+
summary = synthesize_layer_from_results(title, task_results)
|
|
355
|
+
say(" ✓ #{title} synthesis complete")
|
|
356
|
+
{ layer: title, tasks: task_results, summary: summary }
|
|
334
357
|
end
|
|
335
358
|
end
|
|
336
359
|
|
data/lib/aia/logger.rb
CHANGED
|
@@ -76,22 +76,23 @@ module AIA
|
|
|
76
76
|
def configure_llm_logger
|
|
77
77
|
return unless defined?(RubyLLM)
|
|
78
78
|
|
|
79
|
-
logger
|
|
79
|
+
logger = llm_logger
|
|
80
|
+
llm_config = RubyLLM.config
|
|
80
81
|
|
|
81
82
|
# Set our logger on the RubyLLM config object
|
|
82
|
-
if
|
|
83
|
-
|
|
83
|
+
if llm_config.respond_to?(:logger=)
|
|
84
|
+
llm_config.logger = logger
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
# Also set log_file and log_level in case the logger gets recreated
|
|
87
|
-
if
|
|
88
|
+
if llm_config.respond_to?(:log_file=)
|
|
88
89
|
file = effective_log_file(logger_config_for(:llm))
|
|
89
|
-
|
|
90
|
+
llm_config.log_file = resolve_log_file_io(file)
|
|
90
91
|
end
|
|
91
92
|
|
|
92
|
-
if
|
|
93
|
+
if llm_config.respond_to?(:log_level=)
|
|
93
94
|
level = effective_log_level(logger_config_for(:llm))
|
|
94
|
-
|
|
95
|
+
llm_config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
# Reset the memoized @logger on RubyLLM module so next call uses our config
|
|
@@ -307,23 +308,21 @@ module AIA
|
|
|
307
308
|
# @param file [String] The file config value
|
|
308
309
|
# @param flush [Boolean] If true, flush immediately (no buffering)
|
|
309
310
|
# @return [Lumberjack::Device] The device instance
|
|
311
|
+
# :reek:BooleanParameter -- flush: maps directly onto Lumberjack's autoflush; two constructors would obscure that single toggle
|
|
310
312
|
def create_device(file, flush: true)
|
|
311
|
-
# buffer_size: 0 means immediate flush (no buffering)
|
|
312
|
-
buffer_size = flush ? 0 : 8192
|
|
313
|
-
|
|
314
313
|
case file.to_s.upcase
|
|
315
314
|
when 'STDOUT'
|
|
316
|
-
Lumberjack::Device::Writer.new($stdout,
|
|
315
|
+
Lumberjack::Device::Writer.new($stdout, autoflush: flush)
|
|
317
316
|
when 'STDERR'
|
|
318
|
-
Lumberjack::Device::Writer.new($stderr,
|
|
317
|
+
Lumberjack::Device::Writer.new($stderr, autoflush: flush)
|
|
319
318
|
else
|
|
320
319
|
path = File.expand_path(file)
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
Lumberjack::Device::
|
|
320
|
+
# Daily date rolling via Logger::LogDevice, which also makes the
|
|
321
|
+
# file safe for multiple loggers to write to
|
|
322
|
+
Lumberjack::Device::LogFile.new(
|
|
324
323
|
path,
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
shift_age: 'daily',
|
|
325
|
+
autoflush: flush
|
|
327
326
|
)
|
|
328
327
|
end
|
|
329
328
|
end
|
|
@@ -333,12 +332,13 @@ module AIA
|
|
|
333
332
|
# @param system [Symbol] The system (:aia, :llm, :mcp)
|
|
334
333
|
# @return [ConfigSection, nil] The configuration section
|
|
335
334
|
def logger_config_for(system)
|
|
336
|
-
|
|
335
|
+
logger_cfg = AIA.config&.logger
|
|
336
|
+
return nil unless logger_cfg
|
|
337
337
|
|
|
338
338
|
case system
|
|
339
|
-
when :aia then
|
|
340
|
-
when :llm then
|
|
341
|
-
when :mcp then
|
|
339
|
+
when :aia then logger_cfg.aia
|
|
340
|
+
when :llm then logger_cfg.llm
|
|
341
|
+
when :mcp then logger_cfg.mcp
|
|
342
342
|
end
|
|
343
343
|
rescue NoMethodError
|
|
344
344
|
nil
|
|
@@ -10,6 +10,7 @@ require 'timeout'
|
|
|
10
10
|
require 'tty-spinner'
|
|
11
11
|
|
|
12
12
|
module AIA
|
|
13
|
+
# :reek:TooManyMethods -- one small helper per connection-lifecycle concern (connect, wrap tools, record outcomes)
|
|
13
14
|
class MCPConnectionManager
|
|
14
15
|
# Maximum seconds to wait for a single MCP server to connect.
|
|
15
16
|
DEFAULT_TIMEOUT = 30
|
|
@@ -30,6 +31,7 @@ module AIA
|
|
|
30
31
|
#
|
|
31
32
|
# @param servers [Array<Hash>] MCP server configurations
|
|
32
33
|
# @return [self]
|
|
34
|
+
# :reek:TooManyStatements -- connection ceremony: spinner setup, thread launch, global timeout guard, summary logging
|
|
33
35
|
def connect_all(servers)
|
|
34
36
|
return self unless servers.is_a?(Array) && servers.any?
|
|
35
37
|
|
|
@@ -76,9 +78,10 @@ module AIA
|
|
|
76
78
|
connected_keys, tool_counts, failed = @mutex.synchronize do
|
|
77
79
|
[@connected_clients.keys.dup, @server_tool_counts.dup, @failed_servers.dup]
|
|
78
80
|
end
|
|
79
|
-
AIA.config
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
cfg = AIA.config
|
|
82
|
+
cfg.connected_mcp_servers = connected_keys
|
|
83
|
+
cfg.mcp_server_tool_counts = tool_counts
|
|
84
|
+
cfg.failed_mcp_servers = failed
|
|
82
85
|
self
|
|
83
86
|
end
|
|
84
87
|
|
|
@@ -98,6 +101,7 @@ module AIA
|
|
|
98
101
|
# the connected state so inject_into will include them in the robot.
|
|
99
102
|
#
|
|
100
103
|
# @return [self]
|
|
104
|
+
# :reek:TooManyStatements -- per-client absorb loop: start if needed, extract tools, merge into connected state with error capture
|
|
101
105
|
def absorb_ruby_llm_mcp_clients
|
|
102
106
|
return self unless defined?(RubyLLM::MCP)
|
|
103
107
|
|
|
@@ -189,14 +193,17 @@ module AIA
|
|
|
189
193
|
logger = AIA::LoggerManager.mcp_logger
|
|
190
194
|
logger.warn("MCP: Global connection timeout (#{global_limit}s) reached")
|
|
191
195
|
|
|
192
|
-
connected_names = @mutex.synchronize
|
|
193
|
-
|
|
196
|
+
connected_names, failed_names = @mutex.synchronize do
|
|
197
|
+
[@connected_clients.keys.to_set, @failed_servers.to_set { |f| f[:name] }]
|
|
198
|
+
end
|
|
194
199
|
|
|
195
|
-
servers.
|
|
200
|
+
stragglers = servers.filter_map do |server_config|
|
|
196
201
|
srv_name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
|
|
197
202
|
next if connected_names.include?(srv_name) || failed_names.include?(srv_name)
|
|
198
|
-
|
|
203
|
+
|
|
204
|
+
{ name: srv_name, error: "global connection timeout (#{global_limit}s)" }
|
|
199
205
|
end
|
|
206
|
+
@mutex.synchronize { @failed_servers.concat(stragglers) }
|
|
200
207
|
end
|
|
201
208
|
|
|
202
209
|
# Read global MCP connection timeout from config.
|
|
@@ -208,61 +215,64 @@ module AIA
|
|
|
208
215
|
end
|
|
209
216
|
|
|
210
217
|
# Connect a single MCP server, updating the spinner on completion.
|
|
211
|
-
#
|
|
218
|
+
# :reek:TooManyStatements -- one connect attempt with per-outcome reporting (success, refused, timeout, error)
|
|
212
219
|
def connect_one(server_config, name, spinner, logger)
|
|
213
220
|
timeout = server_timeout(server_config)
|
|
214
221
|
spinner.auto_spin
|
|
215
222
|
|
|
216
223
|
logger.info("MCP: connecting to '#{name}'...")
|
|
217
|
-
# rubocop:disable Metrics/BlockLength
|
|
218
224
|
Timeout.timeout(timeout) do
|
|
219
225
|
client = RobotLab::MCP::Client.new(server_config)
|
|
220
226
|
client.connect
|
|
221
227
|
|
|
222
228
|
if client.connected?
|
|
223
|
-
|
|
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)")
|
|
229
|
+
record_connected(name, client, build_mcp_tools(client, name), spinner, logger)
|
|
243
230
|
else
|
|
244
|
-
|
|
245
|
-
@failed_servers << { name: name, error: "connection failed" }
|
|
246
|
-
end
|
|
231
|
+
record_failed_server(name, "connection failed")
|
|
247
232
|
logger.warn("MCP: '#{name}' failed to connect")
|
|
248
233
|
spinner.error("(connection failed)")
|
|
249
234
|
end
|
|
250
235
|
end
|
|
251
|
-
# rubocop:enable Metrics/BlockLength
|
|
252
236
|
rescue Timeout::Error
|
|
253
|
-
|
|
254
|
-
@failed_servers << { name: name, error: "timed out after #{timeout}s" }
|
|
255
|
-
end
|
|
237
|
+
record_failed_server(name, "timed out after #{timeout}s")
|
|
256
238
|
logger.warn("MCP: '#{name}' timed out after #{timeout}s")
|
|
257
239
|
spinner.error("(timed out)")
|
|
258
240
|
rescue StandardError => e
|
|
241
|
+
msg = e.message
|
|
242
|
+
record_failed_server(name, msg)
|
|
243
|
+
logger.warn("MCP: '#{name}' error: #{msg}")
|
|
244
|
+
spinner.error("(#{msg})")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Wrap each remote tool definition in a RobotLab::Tool proxying to the client.
|
|
248
|
+
def build_mcp_tools(client, server_name)
|
|
249
|
+
client.list_tools.map do |tool_def|
|
|
250
|
+
tool_name = tool_def[:name]
|
|
251
|
+
RobotLab::Tool.create(
|
|
252
|
+
name: tool_name,
|
|
253
|
+
description: tool_def[:description],
|
|
254
|
+
parameters: tool_def[:inputSchema],
|
|
255
|
+
mcp: server_name
|
|
256
|
+
) { |args| client.call_tool(tool_name, args) }
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def record_connected(name, client, tools, spinner, logger)
|
|
261
|
+
tool_count = tools.size
|
|
262
|
+
|
|
259
263
|
@mutex.synchronize do
|
|
260
|
-
@
|
|
264
|
+
@connected_clients[name] = client
|
|
265
|
+
@server_tool_counts[name] = tool_count
|
|
266
|
+
add_tools_deduped(tools, name, logger)
|
|
261
267
|
end
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
|
|
269
|
+
logger.info("MCP: '#{name}' connected (#{tool_count} tools)")
|
|
270
|
+
spinner.success("(#{tool_count} tools)")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def record_failed_server(name, error)
|
|
274
|
+
@mutex.synchronize { @failed_servers << { name: name, error: error } }
|
|
264
275
|
end
|
|
265
|
-
# rubocop:enable Metrics/MethodLength
|
|
266
276
|
|
|
267
277
|
# Add tools to @connected_tools, skipping any whose name is already present.
|
|
268
278
|
# Logs a warning for each duplicate so the user knows which server wins.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# lib/aia/mcp_server_config.rb
|
|
2
|
+
|
|
3
|
+
module AIA
|
|
4
|
+
# Normalized view of one MCP server hash. Server configs arrive with
|
|
5
|
+
# symbol or string keys, and with connection fields either nested under
|
|
6
|
+
# :transport or at the top level; this object owns all of that fallback
|
|
7
|
+
# logic so callers never touch the raw hash shape.
|
|
8
|
+
McpServerConfig = Data.define(:name, :command, :args, :env, :timeout_raw) do
|
|
9
|
+
def self.from_hash(server)
|
|
10
|
+
transport = fetch_key(server, :transport) || {}
|
|
11
|
+
new(
|
|
12
|
+
name: AIA::Utility.server_name(server),
|
|
13
|
+
command: fetch_key(transport, :command) || fetch_key(server, :command),
|
|
14
|
+
args: Array(fetch_key(transport, :args) || fetch_key(server, :args)),
|
|
15
|
+
env: fetch_key(transport, :env) || fetch_key(server, :env) || {},
|
|
16
|
+
timeout_raw: fetch_key(server, :timeout)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Read a hash entry by symbol or string key.
|
|
21
|
+
def self.fetch_key(hash, key) = hash[key] || hash[key.to_s]
|
|
22
|
+
|
|
23
|
+
# Timeout in milliseconds; raw values below 1000 are treated as seconds.
|
|
24
|
+
def timeout_ms(default: 8_000, cap: 30_000)
|
|
25
|
+
raw = (timeout_raw || default).to_i
|
|
26
|
+
ms = raw < 1000 ? raw * 1000 : raw
|
|
27
|
+
[ms, cap].min
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/aia/mcp_utility.rb
CHANGED
|
@@ -26,14 +26,17 @@ module AIA
|
|
|
26
26
|
AIA.config&.failed_mcp_servers || []
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# :reek:TooManyStatements -- server-name resolution applying no_mcp, use-list, and skip-list filters in precedence order
|
|
29
30
|
def effective_mcp_server_names
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
cfg = AIA.config
|
|
32
|
+
return [] if cfg&.flags&.no_mcp
|
|
33
|
+
|
|
34
|
+
servers = cfg&.mcp_servers || []
|
|
32
35
|
return [] if servers.empty?
|
|
33
36
|
|
|
34
37
|
names = servers.map { |s| server_name(s) }.compact
|
|
35
|
-
use_list = Array(
|
|
36
|
-
skip_list = Array(
|
|
38
|
+
use_list = Array(cfg.mcp_use)
|
|
39
|
+
skip_list = Array(cfg.mcp_skip)
|
|
37
40
|
|
|
38
41
|
if use_list.any?
|
|
39
42
|
names.select { |n| use_list.include?(n) }
|