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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +3 -223
  4. data/.reek.yml +160 -0
  5. data/.rubocop_strict.yml +15 -0
  6. data/.version +1 -1
  7. data/CHANGELOG.md +10 -5
  8. data/README.md +1 -1
  9. data/Rakefile +0 -113
  10. data/_typos.toml +10 -0
  11. data/aia.gemspec +92 -0
  12. data/config/aia.yml +13 -0
  13. data/lib/aia/chat_loop.rb +8 -52
  14. data/lib/aia/config/cli_parser.rb +66 -57
  15. data/lib/aia/config/mcp_parser.rb +27 -19
  16. data/lib/aia/config/validator.rb +83 -94
  17. data/lib/aia/config.rb +3 -2
  18. data/lib/aia/content_extractor.rb +2 -0
  19. data/lib/aia/cost_calculator.rb +1 -0
  20. data/lib/aia/debate_handler.rb +28 -20
  21. data/lib/aia/delegate_handler.rb +7 -3
  22. data/lib/aia/directive.rb +10 -8
  23. data/lib/aia/directives/configuration_directives.rb +115 -101
  24. data/lib/aia/directives/context_directives.rb +31 -28
  25. data/lib/aia/directives/execution_directives.rb +18 -13
  26. data/lib/aia/directives/model_directives.rb +134 -154
  27. data/lib/aia/directives/trakflow_directives.rb +27 -9
  28. data/lib/aia/directives/utility_directives.rb +71 -71
  29. data/lib/aia/directives/web_and_file_directives.rb +63 -50
  30. data/lib/aia/layered_orchestrator.rb +66 -43
  31. data/lib/aia/logger.rb +21 -21
  32. data/lib/aia/mcp_connection_manager.rb +50 -40
  33. data/lib/aia/mcp_server_config.rb +30 -0
  34. data/lib/aia/mcp_utility.rb +7 -4
  35. data/lib/aia/mention_router.rb +7 -50
  36. data/lib/aia/network_builder.rb +10 -5
  37. data/lib/aia/network_memory_manager.rb +6 -6
  38. data/lib/aia/pipeline_orchestrator.rb +20 -10
  39. data/lib/aia/plugin_monitor.rb +9 -6
  40. data/lib/aia/prompt_decomposer.rb +5 -3
  41. data/lib/aia/prompt_handler.rb +43 -49
  42. data/lib/aia/robot_factory.rb +12 -8
  43. data/lib/aia/robot_namer.rb +2 -8
  44. data/lib/aia/session.rb +7 -3
  45. data/lib/aia/session_tracker.rb +39 -41
  46. data/lib/aia/similarity_scorer.rb +5 -3
  47. data/lib/aia/special_mode_handler.rb +52 -32
  48. data/lib/aia/speech.rb +67 -0
  49. data/lib/aia/startup_coordinator.rb +1 -0
  50. data/lib/aia/streaming_runner.rb +6 -3
  51. data/lib/aia/system_prompt_assembler.rb +11 -7
  52. data/lib/aia/task_coordinator.rb +10 -5
  53. data/lib/aia/timing.rb +15 -0
  54. data/lib/aia/tool_filter.rb +1 -0
  55. data/lib/aia/tool_filter_strategy.rb +28 -25
  56. data/lib/aia/tool_introspection.rb +17 -0
  57. data/lib/aia/tool_loader.rb +12 -6
  58. data/lib/aia/tools/task_board_tool.rb +1 -0
  59. data/lib/aia/trakflow_bridge.rb +5 -3
  60. data/lib/aia/turn_state.rb +1 -0
  61. data/lib/aia/ui_presenter.rb +67 -59
  62. data/lib/aia/utility.rb +5 -3
  63. data/lib/aia/variable_input_collector.rb +1 -0
  64. data/lib/aia/verification_network.rb +1 -2
  65. data/lib/aia.rb +16 -0
  66. metadata +15 -9
  67. data/.quality/flay_baseline.txt +0 -1
  68. data/.quality/flog_baseline.txt +0 -29
  69. data/.quality/reek_baseline.txt +0 -80
@@ -15,11 +15,13 @@ module AIA
15
15
  #
16
16
  # @param responses [Array<String>] ordered response texts (first is reference)
17
17
  # @return [Array<Float, nil>] similarity scores (nil for first, 0.0..1.0 for rest)
18
+ # :reek:TooManyStatements -- guarded scoring: two early-out shapes, TF-IDF fit/transform, pairwise map, rescue fallback
18
19
  def self.score(responses)
19
- return Array.new(responses.size) if responses.size < 2
20
+ count = responses.size
21
+ return Array.new(count) if count < 2
20
22
 
21
23
  texts = responses.map { |r| r.to_s.strip }
22
- return Array.new(responses.size) if texts.first.empty?
24
+ return Array.new(count) if texts.first.empty?
23
25
 
24
26
  tfidf = Classifier::TFIDF.new
25
27
  tfidf.fit(texts)
@@ -33,7 +35,7 @@ module AIA
33
35
  end
34
36
  end
35
37
  rescue StandardError
36
- Array.new(responses.size)
38
+ Array.new(count)
37
39
  end
38
40
  end
39
41
  end
@@ -109,30 +109,63 @@ module AIA
109
109
  false
110
110
  end
111
111
 
112
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
112
+ # :reek:TooManyStatements -- benchmark-instrumented pipeline: decompose, concurrent fan-out, synthesis, reporting
113
113
  def handle_decomposition(prompt)
114
114
  require_relative 'prompt_decomposer'
115
- total_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
+ total_t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
116
116
 
117
117
  @ui_presenter.display_info("Decomposing prompt into sub-tasks...")
118
- decomposer = PromptDecomposer.new(@robot)
119
- decompose_t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
- subtasks = decomposer.decompose(prompt)
121
- decompose_dur = Process.clock_gettime(Process::CLOCK_MONOTONIC) - decompose_t0
118
+ decomposer = PromptDecomposer.new(@robot)
119
+ subtasks, decompose_dur = Timing.timed { decomposer.decompose(prompt) }
122
120
 
123
121
  if subtasks.empty?
124
122
  @ui_presenter.display_info("Prompt cannot be meaningfully decomposed. Running normally.")
125
123
  return false
126
124
  end
127
125
 
126
+ announce_subtasks(subtasks)
127
+
128
+ results, timings, raw_subtasks, parallel_wall = run_and_time_subtasks(subtasks)
129
+
130
+ raise DecomposeError, "All sub-tasks failed" if results.all?(&:nil?)
131
+
132
+ @ui_presenter.display_info("Synthesizing results...")
133
+ final, synth_dur = Timing.timed { decomposer.synthesize(prompt, results.compact) }
134
+
135
+ total_wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - total_t0
136
+
137
+ display_decompose_benchmark(decompose_dur: decompose_dur, timings: timings,
138
+ synth_dur: synth_dur, parallel_wall: parallel_wall,
139
+ total_wall: total_wall)
140
+ display_decompose_token_metrics(raw_subtasks, final, total_wall)
141
+
142
+ present_result(final, prompt: prompt, ui_presenter: @ui_presenter, tracker: @tracker)
143
+ true
144
+ rescue StandardError => e
145
+ @ui_presenter.display_info("Decomposition failed: #{e.message}. Falling back to normal mode.")
146
+ false
147
+ end
148
+
149
+ def announce_subtasks(subtasks)
128
150
  @ui_presenter.display_info("Decomposed into #{subtasks.size} sub-tasks:")
129
151
  subtasks.each_with_index { |t, i| @ui_presenter.display_info(" #{i + 1}. #{t}") }
152
+ end
130
153
 
131
- timings = Array.new(subtasks.size, 0.0)
132
- raw_subtasks = Array.new(subtasks.size)
133
- wall_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
154
+ # Fan out sub-tasks concurrently, capturing per-task and wall-clock timing.
155
+ # Returns [results, timings, raw_subtasks, parallel_wall].
156
+ def run_and_time_subtasks(subtasks)
157
+ timings = Array.new(subtasks.size, 0.0)
158
+ raw_subtasks = Array.new(subtasks.size)
159
+ results, parallel_wall = Timing.timed { run_subtasks_concurrently(subtasks, timings, raw_subtasks) }
160
+ [results, timings, raw_subtasks, parallel_wall]
161
+ end
134
162
 
135
- results = Sync do
163
+ # Run all sub-tasks concurrently, recording per-task timing and raw results
164
+ # into the caller-supplied arrays. Returns extracted contents (nil per failure).
165
+ # :reek:DuplicateMethodCall -- clock_gettime is read at task start and again at each outcome; each read must be "now"
166
+ # :reek:TooManyStatements -- async fan-out with per-task timing capture on both success and failure paths
167
+ def run_subtasks_concurrently(subtasks, timings, raw_subtasks)
168
+ Sync do
136
169
  barrier = Async::Barrier.new
137
170
  tasks = subtasks.each_with_index.map do |task, i|
138
171
  barrier.async do
@@ -151,21 +184,13 @@ module AIA
151
184
  barrier.wait
152
185
  tasks.map(&:wait)
153
186
  end
154
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
187
+ end
155
188
 
156
- parallel_wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_start
189
+ # Print the decomposition benchmark table.
190
+ def display_decompose_benchmark(decompose_dur:, timings:, synth_dur:, parallel_wall:, total_wall:)
157
191
  serial_est = timings.sum
158
192
  parallel_speedup = serial_est / parallel_wall
159
193
 
160
- raise DecomposeError, "All sub-tasks failed" if results.all?(&:nil?)
161
-
162
- @ui_presenter.display_info("Synthesizing results...")
163
- synth_t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
164
- final = decomposer.synthesize(prompt, results.compact)
165
- synth_dur = Process.clock_gettime(Process::CLOCK_MONOTONIC) - synth_t0
166
-
167
- total_wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - total_start
168
-
169
194
  lines = ["", "Benchmark:"]
170
195
  lines << " Decompose probe : #{format('%.2f', decompose_dur)}s"
171
196
  timings.each_with_index { |t, i| lines << " Sub-task #{i + 1} : #{format('%.2f', t)}s" }
@@ -175,21 +200,15 @@ module AIA
175
200
  lines << " Sub-tasks concurrent : #{format('%.2f', parallel_wall)}s (#{format('%.1f', parallel_speedup)}x parallel speedup)"
176
201
  lines << " Total flow wall time : #{format('%.2f', total_wall)}s (compare against --tokens Time for a normal run)"
177
202
  @ui_presenter.display_info(lines.join("\n"))
178
-
179
- display_decompose_token_metrics(raw_subtasks, final, total_wall)
180
-
181
- present_result(final, prompt: prompt, ui_presenter: @ui_presenter, tracker: @tracker)
182
- true
183
- rescue StandardError => e
184
- @ui_presenter.display_info("Decomposition failed: #{e.message}. Falling back to normal mode.")
185
- false
186
203
  end
187
204
 
205
+ # :reek:TooManyStatements -- ordered eligibility gates (server count, discovery, grouping) before the network run
188
206
  def handle_concurrent_mcp(prompt)
189
- return false unless (AIA.config.mcp_servers || []).size > 1
207
+ cfg = AIA.config
208
+ return false unless (cfg.mcp_servers || []).size > 1
190
209
 
191
210
  discovery = MCPDiscovery.new
192
- relevant = discovery.discover(AIA.config)
211
+ relevant = discovery.discover(cfg)
193
212
  return false if relevant.size <= 1
194
213
 
195
214
  grouper = MCPGrouper.new
@@ -198,7 +217,7 @@ module AIA
198
217
 
199
218
  @ui_presenter.display_info("Running concurrent MCP across #{groups.size} server groups...")
200
219
 
201
- network = RobotFactory.build_concurrent_mcp_network(AIA.config, groups)
220
+ network = RobotFactory.build_concurrent_mcp_network(cfg, groups)
202
221
  result = @ui_presenter.with_spinner("Processing (concurrent)") { network.run(message: prompt) }
203
222
  extract_content(result)
204
223
 
@@ -270,6 +289,7 @@ module AIA
270
289
 
271
290
  # Aggregate token counts from all sub-task raw results plus the synthesis
272
291
  # result and display a combined metrics table. Only runs when --tokens is set.
292
+ # :reek:TooManyStatements -- sums token usage across sub-task results plus synthesis, then prints the metrics block
273
293
  def display_decompose_token_metrics(raw_subtasks, synthesis_result, total_elapsed)
274
294
  return unless AIA.config.flags.tokens
275
295
 
data/lib/aia/speech.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/speech.rb
4
+ #
5
+ # Shared text-to-speech behavior for interactive handlers (ChatLoop,
6
+ # MentionRouter). ContentExtractor duck-calls #speak on any includer
7
+ # that responds to it, so both sides route through this one pipeline.
8
+
9
+ module AIA
10
+ module Speech
11
+ private
12
+
13
+ # :reek:TooManyStatements -- two-stage TTS pipeline (convert, then play) with spinner and tempfile cleanup
14
+ def speak(content)
15
+ return unless AIA.speak?
16
+
17
+ audio = AIA.config.audio
18
+ command = audio.speak_command || 'say'
19
+ voice = audio.voice
20
+ text = content.to_s
21
+ env = {}
22
+ env['SPEECH_MODEL'] = audio.speech_model if audio.speech_model
23
+
24
+ if command == 'say'
25
+ # Local TTS: say converts and plays in one step
26
+ run_with_spinner("Speaking...") do
27
+ if voice && !voice.to_s.strip.empty?
28
+ system(env, command, '-v', voice, text)
29
+ else
30
+ system(env, command, text)
31
+ end
32
+ end
33
+ else
34
+ # Custom TTS script: stage 2 = convert text → audio file,
35
+ # stage 3 = play the file. AIA passes the output path as $2.
36
+ require 'tempfile'
37
+ tmpfile = Tempfile.new(['aia-tts-', '.mp3'])
38
+ tmpfile.close
39
+ path = tmpfile.path
40
+ begin
41
+ run_with_spinner("Converting to audio...") do
42
+ system(env, command, text, path)
43
+ end
44
+ if File.size?(path)
45
+ run_with_spinner("Speaking...") do
46
+ system('afplay', path)
47
+ end
48
+ end
49
+ ensure
50
+ tmpfile.unlink
51
+ end
52
+ end
53
+ rescue StandardError => e
54
+ $stderr.puts "Warning: Speech failed: #{e.message}"
55
+ end
56
+
57
+ def run_with_spinner(message)
58
+ spinner = TTY::Spinner.new("[:spinner] #{message}", format: :bouncing_ball, output: $stderr)
59
+ spinner.auto_spin
60
+ begin
61
+ yield
62
+ ensure
63
+ spinner.stop
64
+ end
65
+ end
66
+ end
67
+ end
@@ -46,6 +46,7 @@ module AIA
46
46
  # --mcp-use, --mcp-skip, and KBS mcp_activate decisions), then normalizes
47
47
  # each server config via MCPConfigNormalizer before handing off to
48
48
  # MCPConnectionManager.
49
+ # :reek:TooManyStatements -- MCP wiring sequence: discovery, validation, normalize fallback, connect, absorb, inject
49
50
  def connect_mcp_servers(config)
50
51
  return if config.flags.no_mcp
51
52
 
@@ -28,6 +28,8 @@ module AIA
28
28
  # @param spinner_message [String] spinner label
29
29
  # @param tools [Array<String>, nil] tool names to allow for this turn (nil = all)
30
30
  # @return [Array(Object, String, Float)] [result, streamed_content_or_nil, elapsed_seconds]
31
+ # :reek:TooManyStatements -- one streaming turn: spinner lifecycle, chunk-filter closure, robot run with cleanup, timing
32
+ # :reek:DuplicateMethodCall -- @spinner.stop guards three distinct control paths (first chunk, exception, completion)
31
33
  def run(robot, prompt, header: "\nAI:\n ", spinner_message: "Processing...", tools: nil)
32
34
  @spinner.reset
33
35
  @spinner.update(title: spinner_message)
@@ -107,11 +109,12 @@ module AIA
107
109
 
108
110
  max = max_tools
109
111
  names = tools_param == :inherit ? robot_tool_names(robot) : tools_param
110
- return tools_param if names.size <= max
112
+ size = names.size
113
+ return tools_param if size <= max
111
114
 
112
- dropped = names.size - max
115
+ dropped = size - max
113
116
  $stderr.puts(
114
- "⚠ Tool list (#{names.size}) exceeds the provider limit of #{max}; " \
117
+ "⚠ Tool list (#{size}) exceeds the provider limit of #{max}; " \
115
118
  "sending #{max}, dropping #{dropped}. Enable --auto-tool-filter or reduce " \
116
119
  "the available tools to control which ones are sent."
117
120
  )
@@ -18,9 +18,10 @@ module AIA
18
18
  # @param model_spec [ModelSpec, nil] optional model spec with role override
19
19
  # @return [String, nil] the assembled system prompt
20
20
  def resolve_system_prompt(config, model_spec = nil)
21
- system_prompt = config.prompts.system_prompt
21
+ prompts_cfg = config.prompts
22
+ system_prompt = prompts_cfg.system_prompt
22
23
 
23
- role_id = model_spec&.role || config.prompts.role
24
+ role_id = model_spec&.role || prompts_cfg.role
24
25
  if role_id && !role_id.empty?
25
26
  role_content = load_role_content(config, role_id)
26
27
  if role_content
@@ -32,7 +33,7 @@ module AIA
32
33
  # Pipeline mode appends skills to each prompt text instead (pipeline_orchestrator).
33
34
  if config.flags&.chat == true
34
35
  skill_content = AIA::SkillUtils.load_skills_content(
35
- Array(config.prompts&.skills),
36
+ Array(prompts_cfg&.skills),
36
37
  AIA::SkillUtils.skills_base_dir(config)
37
38
  )
38
39
  system_prompt = [system_prompt, skill_content].compact.join("\n\n") if skill_content
@@ -48,6 +49,7 @@ module AIA
48
49
  # @param spec [ModelSpec] this robot's model spec
49
50
  # @param roster [Array<Hash>] all robots: [{ name:, spec: }, ...]
50
51
  # @return [String]
52
+ # :reek:TooManyStatements -- builds the identity line plus a roster entry per teammate; one cohesive prompt fragment
51
53
  def build_identity_prompt(robot_name, spec, roster)
52
54
  provider_label = spec.provider ? " (#{spec.provider})" : ""
53
55
  lines = ["You are #{robot_name}, powered by #{spec.name}#{provider_label}."]
@@ -55,9 +57,10 @@ module AIA
55
57
  if roster.size > 1
56
58
  lines << "You are part of a team of AI robots:"
57
59
  roster.each do |entry|
58
- p = entry[:spec].provider ? " (#{entry[:spec].provider})" : ""
60
+ entry_spec = entry[:spec]
61
+ p = entry_spec.provider ? " (#{entry_spec.provider})" : ""
59
62
  marker = entry[:name] == robot_name ? " ← you" : ""
60
- lines << " - #{entry[:name]}: #{entry[:spec].name}#{p}#{marker}"
63
+ lines << " - #{entry[:name]}: #{entry_spec.name}#{p}#{marker}"
61
64
  end
62
65
  lines << "Users can address a specific robot with @name mentions."
63
66
  end
@@ -71,12 +74,13 @@ module AIA
71
74
  # @param role_id [String] the role identifier
72
75
  # @return [String, nil] the role file content or nil
73
76
  def load_role_content(config, role_id)
74
- roles_prefix = config.prompts.roles_prefix
77
+ prompts_cfg = config.prompts
78
+ roles_prefix = prompts_cfg.roles_prefix
75
79
  unless role_id.start_with?(roles_prefix)
76
80
  role_id = "#{roles_prefix}/#{role_id}"
77
81
  end
78
82
 
79
- role_file = File.join(config.prompts.dir, "#{role_id}#{config.prompts.extname}")
83
+ role_file = File.join(prompts_cfg.dir, "#{role_id}#{prompts_cfg.extname}")
80
84
  return nil unless File.exist?(role_file)
81
85
 
82
86
  File.read(role_file)
@@ -33,6 +33,7 @@ module AIA
33
33
  # @param labels [Array<String>] labels like "robot:alice", "domain:code"
34
34
  # @param creator [String] name of the creating robot
35
35
  # @return [TrakFlow::Models::Task, nil]
36
+ # :reek:LongParameterList { max_params: 6 } -- one keyword per optional task attribute; callers set only what they need
36
37
  def create_task(title, assignee: nil, parent_id: nil,
37
38
  blocked_by: [], labels: [], creator: "aia")
38
39
  return nil unless available?
@@ -40,27 +41,28 @@ module AIA
40
41
  task = TrakFlow::Models::Task.new(
41
42
  title: title, assignee: assignee, type: "task"
42
43
  )
43
- task = @db.create_task(task)
44
+ task = @db.create_task(task)
45
+ task_id = task.id
44
46
 
45
47
  @db.add_label(TrakFlow::Models::Label.new(
46
- task_id: task.id, name: "creator:#{creator}"
48
+ task_id: task_id, name: "creator:#{creator}"
47
49
  ))
48
50
 
49
51
  labels.each do |label|
50
52
  @db.add_label(TrakFlow::Models::Label.new(
51
- task_id: task.id, name: label
53
+ task_id: task_id, name: label
52
54
  ))
53
55
  end
54
56
 
55
57
  blocked_by.each do |blocker_id|
56
58
  @db.add_dependency(TrakFlow::Models::Dependency.new(
57
- source_id: blocker_id, target_id: task.id, type: "blocks"
59
+ source_id: blocker_id, target_id: task_id, type: "blocks"
58
60
  ))
59
61
  end
60
62
 
61
63
  if parent_id
62
64
  @db.add_dependency(TrakFlow::Models::Dependency.new(
63
- source_id: parent_id, target_id: task.id, type: "parent-child"
65
+ source_id: parent_id, target_id: task_id, type: "parent-child"
64
66
  ))
65
67
  end
66
68
 
@@ -74,6 +76,8 @@ module AIA
74
76
  # @param creator [String] creating robot name
75
77
  # @param ephemeral [Boolean] true for single-session plans (auto-gc)
76
78
  # @return [Hash, nil] { plan:, steps: }
79
+ # :reek:BooleanParameter -- ephemeral: tags a plan for auto-gc at session end; a second method would duplicate the whole build
80
+ # :reek:TooManyStatements -- plan build: create the plan task, then per step a child task, labels, and ordering dependency
77
81
  def create_plan(title, steps:, creator: "aia", ephemeral: false)
78
82
  return nil unless available?
79
83
 
@@ -177,6 +181,7 @@ module AIA
177
181
  # Summary of the current task board state.
178
182
  #
179
183
  # @return [String, nil]
184
+ # :reek:TooManyStatements -- one board report: totals line, per-assignee open/done counts, unassigned tail
180
185
  def status_summary
181
186
  return nil unless available?
182
187
 
data/lib/aia/timing.rb ADDED
@@ -0,0 +1,15 @@
1
+ # lib/aia/timing.rb
2
+
3
+ module AIA
4
+ # Monotonic-clock instrumentation shared by benchmark-reporting code.
5
+ module Timing
6
+ module_function
7
+
8
+ # Run the block and return [result, elapsed_seconds].
9
+ def timed
10
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ result = yield
12
+ [result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0]
13
+ end
14
+ end
15
+ end
@@ -13,6 +13,7 @@ module AIA
13
13
  class ToolFilter
14
14
  attr_reader :tool_count, :prep_ms, :label
15
15
 
16
+ # :reek:BooleanParameter -- load_db:/save_db: toggle embedding-cache persistence per strategy; mode objects would multiply constructors
16
17
  def initialize(label:, db_dir: nil, load_db: false, save_db: false)
17
18
  @label = label
18
19
  @db_dir = db_dir
@@ -8,6 +8,8 @@
8
8
  # After each resolve(), prints a timing table showing
9
9
  # prep and filter times for the active filter.
10
10
 
11
+ require 'tty-table'
12
+
11
13
  module AIA
12
14
  class ToolFilterStrategy
13
15
  # Maps filter keys to display info.
@@ -88,26 +90,45 @@ module AIA
88
90
 
89
91
  # Display single-filter results via logger.
90
92
  def display_filter_results(key, scored)
91
- meta = meta_for(key)
93
+ meta = meta_for(key)
94
+ label = meta[:label]
95
+ logger = AIA.logger
92
96
 
93
97
  if scored.empty?
94
- AIA.logger.debug "[#{meta[:label]}] No tools matched (all tools available)"
98
+ logger.debug "[#{label}] No tools matched (all tools available)"
95
99
  return
96
100
  end
97
101
 
98
102
  names = scored.map { |e| e[:name] }.join(', ')
99
- AIA.logger.debug "[#{meta[:label]}] Tools for this turn (#{scored.size}): #{names}"
103
+ logger.debug "[#{label}] Tools for this turn (#{scored.size}): #{names}"
100
104
 
101
105
  scored.each do |entry|
102
- AIA.logger.debug "[#{meta[:label]}] #{entry[:name]} (#{meta[:score_label]}: #{format('%.4f', entry[:score])})"
106
+ logger.debug "[#{label}] #{entry[:name]} (#{meta[:score_label]}: #{format('%.4f', entry[:score])})"
103
107
  end
104
108
  end
105
109
 
106
110
  # Print the timing table via logger.
107
- # rubocop:disable Metrics/AbcSize
111
+ # :reek:DuplicateMethodCall -- header and each data row map the same columns into different cells
108
112
  def display_timing_table(filter_ms_by_key)
109
- columns = @filters.map do |key, filter|
110
- meta = meta_for(key)
113
+ columns = build_timing_columns(filter_ms_by_key)
114
+ return if columns.empty?
115
+
116
+ header = ["Process"] + columns.map { |col| "#{col[:header]}\n#{col[:sub]}" }
117
+ rows = [
118
+ ["prep"] + columns.map { |col| col[:prep] },
119
+ ["filter"] + columns.map { |col| col[:filter] }
120
+ ]
121
+
122
+ alignments = [:left] + Array.new(columns.size, :right)
123
+ rendered = TTY::Table.new(header, rows)
124
+ .render(:unicode, multiline: true, alignments: alignments, padding: [0, 1])
125
+ rendered.each_line { |line| AIA.logger.debug(line.chomp) }
126
+ end
127
+
128
+ # Column data (header, sub-label, prep/filter cells) for each configured filter.
129
+ def build_timing_columns(filter_ms_by_key)
130
+ @filters.map do |key, filter|
131
+ meta = meta_for(key)
111
132
  active = filter_ms_by_key.key?(key)
112
133
  {
113
134
  header: "Option #{meta[:letter]}",
@@ -116,25 +137,7 @@ module AIA
116
137
  filter: active ? fmt_ms(filter_ms_by_key[key]) : "--"
117
138
  }
118
139
  end
119
-
120
- return if columns.empty?
121
-
122
- pw = 7 # "Process" column width
123
- widths = columns.map do |col|
124
- [10, col[:header].length, col[:sub].length, col[:prep].length, col[:filter].length].max
125
- end
126
-
127
- lines = []
128
- lines << ("┌─#{'─' * pw}─" + columns.each_with_index.map { |_, i| "┬─#{'─' * widths[i]}─" }.join + "┐")
129
- lines << ("│ #{'Process'.ljust(pw)} " + columns.each_with_index.map { |col, i| "│ #{col[:header].ljust(widths[i])} " }.join + "│")
130
- lines << ("│ #{' ' * pw} " + columns.each_with_index.map { |col, i| "│ #{col[:sub].ljust(widths[i])} " }.join + "│")
131
- lines << ("├─#{'─' * pw}─" + columns.each_with_index.map { |_, i| "┼─#{'─' * widths[i]}─" }.join + "┤")
132
- lines << ("│ #{'prep'.ljust(pw)} " + columns.each_with_index.map { |col, i| "│ #{col[:prep].rjust(widths[i])} " }.join + "│")
133
- lines << ("│ #{'filter'.ljust(pw)} " + columns.each_with_index.map { |col, i| "│ #{col[:filter].rjust(widths[i])} " }.join + "│")
134
- lines << ("└─#{'─' * pw}─" + columns.each_with_index.map { |_, i| "┴─#{'─' * widths[i]}─" }.join + "┘")
135
- lines.each { |line| AIA.logger.debug line }
136
140
  end
137
- # rubocop:enable Metrics/AbcSize
138
141
 
139
142
  def fmt_ms(ms)
140
143
  "#{ms.round(1)}ms"
@@ -0,0 +1,17 @@
1
+ # lib/aia/tool_introspection.rb
2
+
3
+ module AIA
4
+ # Shared name/description extraction for tool objects, which may be
5
+ # RubyLLM tools, RobotLab tools, or bare tool classes.
6
+ module ToolIntrospection
7
+ module_function
8
+
9
+ def tool_name(tool)
10
+ tool.respond_to?(:name) ? tool.name : tool.class.name
11
+ end
12
+
13
+ def tool_description(tool)
14
+ tool.respond_to?(:description) ? tool.description.to_s : ''
15
+ end
16
+ end
17
+ end
@@ -53,6 +53,7 @@ module AIA
53
53
 
54
54
  # Load tools from require_libs and tool paths, then cache the result.
55
55
  # Subsequent calls to RobotFactory.build skip this entirely if cache exists.
56
+ # :reek:TooManyStatements -- sequential phases: require_libs with activation retry, tool files, eager loads, discovery cache
56
57
  def load_tools(config)
57
58
  Array(config.require_libs).each do |lib|
58
59
  require lib
@@ -89,11 +90,17 @@ module AIA
89
90
  tools = discover_tools
90
91
  @tool_cache = tools
91
92
  config.loaded_tools = tools
92
- config.tool_names = tools.map { |t| t.respond_to?(:name) ? t.name : t.class.name }.join(', ')
93
+ config.tool_names = tools.map { |t| tool_name(t) }.join(', ')
94
+ end
95
+
96
+ # A tool's display name: instances expose #name; bare classes fall back to the class name.
97
+ def tool_name(tool)
98
+ tool.respond_to?(:name) ? tool.name : tool.class.name
93
99
  end
94
100
 
95
101
  # Filter tools based on allowed/rejected lists and KBS decisions.
96
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
102
+ # :reek:TooManyStatements -- allowed filter, rejected filter, then a name-dedup pass over the tool list
103
+ # :reek:DuplicateMethodCall -- tool_name(t) is applied per element inside three separate filter blocks; no shared scope to hoist into
97
104
  def filtered_tools(config)
98
105
  tools = config.loaded_tools || []
99
106
  allowed = config.tools&.allowed
@@ -102,7 +109,7 @@ module AIA
102
109
  if allowed && !allowed.empty?
103
110
  allowed_list = Array(allowed).map(&:strip).map(&:downcase)
104
111
  tools = tools.select do |t|
105
- name = (t.respond_to?(:name) ? t.name : t.class.name).downcase
112
+ name = tool_name(t).downcase
106
113
  # name is a String; Array#intersect? would raise TypeError
107
114
  allowed_list.any? { |a| name.include?(a) }
108
115
  end
@@ -111,7 +118,7 @@ module AIA
111
118
  if rejected && !rejected.empty?
112
119
  rejected_list = Array(rejected).map(&:strip).map(&:downcase)
113
120
  tools = tools.reject do |t|
114
- name = (t.respond_to?(:name) ? t.name : t.class.name).downcase
121
+ name = tool_name(t).downcase
115
122
  # name is a String; Array#intersect? would raise TypeError
116
123
  rejected_list.any? { |r| name.include?(r) }
117
124
  end
@@ -119,7 +126,7 @@ module AIA
119
126
 
120
127
  seen = {}
121
128
  tools.select do |t|
122
- name = t.respond_to?(:name) ? t.name : t.class.name
129
+ name = tool_name(t)
123
130
  if seen[name]
124
131
  false
125
132
  else
@@ -128,7 +135,6 @@ module AIA
128
135
  end
129
136
  end
130
137
  end
131
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
132
138
 
133
139
  # Discover RubyLLM::Tool subclasses from ObjectSpace.
134
140
  # Skips tools that report themselves as unavailable via #available?.
@@ -9,6 +9,7 @@
9
9
  require "json"
10
10
 
11
11
  module AIA
12
+ # :reek:DataClump -- each execute_<action> dispatch target receives the same LLM tool-call context
12
13
  class TaskBoardTool < RubyLLM::Tool
13
14
  description "Manage a shared task board. Create tasks for other robots, " \
14
15
  "check what's ready, claim tasks, and report completion."
@@ -26,6 +26,7 @@ module AIA
26
26
  #
27
27
  # @param pipeline [Array<String>] prompt IDs in pipeline order
28
28
  # @return [String, nil] summary of created plan
29
+ # :reek:TooManyStatements -- plan build: one child task per pipeline step, chained by blocking dependencies
29
30
  def create_plan_from_pipeline(pipeline)
30
31
  return nil unless available?
31
32
 
@@ -71,15 +72,16 @@ module AIA
71
72
  case status
72
73
  when :started
73
74
  task.status = "in_progress"
74
- @db.update_task(task)
75
75
  when :completed
76
76
  task.close!(reason: reason || "Completed")
77
- @db.update_task(task)
78
77
  when :failed
79
78
  task.status = "blocked"
80
79
  task.append_trace("blocked", reason) if reason
81
- @db.update_task(task)
80
+ else
81
+ return
82
82
  end
83
+
84
+ @db.update_task(task)
83
85
  rescue StandardError => e
84
86
  $stderr.puts "Warning: TrakFlow status update failed: #{e.message}"
85
87
  end
@@ -12,6 +12,7 @@
12
12
  # and direct test assertions).
13
13
 
14
14
  module AIA
15
+ # :reek:TooManyInstanceVariables -- one boolean flag per exclusive mode plus spawn/mcp details; a Hash would lose the attr_accessor API
15
16
  class TurnState
16
17
  # Exclusive mode flags — only one may be active at a time via #request.
17
18
  EXCLUSIVE_MODES = %i[