aia 1.1.1 → 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.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +231 -0
  4. data/.quality/flay_baseline.txt +1 -0
  5. data/.quality/flog_baseline.txt +29 -0
  6. data/.quality/reek_baseline.txt +80 -0
  7. data/.rubocop.yml +116 -0
  8. data/.version +1 -1
  9. data/CHANGELOG.md +259 -50
  10. data/IMPLEMENTATION_PLAN.md +506 -0
  11. data/README.md +266 -238
  12. data/Rakefile +118 -5
  13. data/architecture_review.md +314 -0
  14. data/bin/aia +16 -0
  15. data/docs/AGENTS.md +40 -0
  16. data/docs/advanced-prompting.md +67 -3
  17. data/docs/cli-reference.md +312 -56
  18. data/docs/configuration.md +130 -19
  19. data/docs/contributing.md +56 -2
  20. data/docs/directives-reference.md +593 -78
  21. data/docs/faq.md +85 -3
  22. data/docs/guides/available-models.md +1 -1
  23. data/docs/guides/basic-usage.md +6 -6
  24. data/docs/guides/chat.md +40 -16
  25. data/docs/guides/crew.md +239 -0
  26. data/docs/guides/executable-prompts.md +1 -1
  27. data/docs/guides/index.md +1 -0
  28. data/docs/guides/models.md +15 -0
  29. data/docs/index.md +29 -2
  30. data/docs/installation.md +44 -17
  31. data/docs/mcp-integration.md +40 -0
  32. data/docs/prompt_management.md +85 -86
  33. data/docs/security.md +47 -0
  34. data/docs/special_projects_guide.md +386 -0
  35. data/docs/tools-and-mcp-examples.md +23 -0
  36. data/docs/workflows-and-pipelines.md +84 -7
  37. data/examples/.gitignore +1 -0
  38. data/examples/00_setup_aia.sh +27 -44
  39. data/examples/11_multi_model.sh +4 -14
  40. data/examples/12_token_usage.sh +3 -12
  41. data/examples/18_tools.sh +10 -2
  42. data/examples/22_chat_mode.sh +0 -10
  43. data/examples/23_verify.sh +139 -0
  44. data/examples/24_decompose.sh +139 -0
  45. data/examples/25_spawn.sh +139 -0
  46. data/examples/26_debate.sh +97 -0
  47. data/examples/27_mention_routing.sh +157 -0
  48. data/examples/28_model_switching.sh +106 -0
  49. data/examples/29_agent_harness.sh +177 -0
  50. data/examples/README.md +65 -0
  51. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  52. data/examples/aia_config.yml +1 -1
  53. data/examples/aia_config_orchestrator.yml +45 -0
  54. data/examples/common.sh +18 -6
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  57. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  58. data/examples/rules/01_classify_ruby.rb +16 -0
  59. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  60. data/examples/rules/03_gate_prompt_length.rb +19 -0
  61. data/examples/rules/04_tool_selection.rb +41 -0
  62. data/examples/rules/README.md +30 -0
  63. data/examples/run_all.sh +48 -15
  64. data/examples/tools/word_count_tool.rb +1 -1
  65. data/lib/AGENTS.md +57 -0
  66. data/lib/aia/chat_loop.rb +304 -164
  67. data/lib/aia/config/cli_parser.rb +174 -111
  68. data/lib/aia/config/defaults.yml +62 -33
  69. data/lib/aia/config/mcp_parser.rb +39 -46
  70. data/lib/aia/config/model_spec.rb +34 -2
  71. data/lib/aia/config/validator.rb +108 -142
  72. data/lib/aia/config.rb +110 -145
  73. data/lib/aia/content_extractor.rb +153 -0
  74. data/lib/aia/cost_calculator.rb +38 -0
  75. data/lib/aia/crew.rb +164 -0
  76. data/lib/aia/debate_handler.rb +166 -0
  77. data/lib/aia/delegate_handler.rb +112 -0
  78. data/lib/aia/directive.rb +33 -18
  79. data/lib/aia/directive_processor.rb +16 -7
  80. data/lib/aia/directives/configuration_directives.rb +160 -20
  81. data/lib/aia/directives/context_directives.rb +38 -26
  82. data/lib/aia/directives/execution_directives.rb +136 -4
  83. data/lib/aia/directives/model_directives.rb +76 -34
  84. data/lib/aia/directives/trakflow_directives.rb +44 -0
  85. data/lib/aia/directives/utility_directives.rb +203 -6
  86. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  87. data/lib/aia/errors.rb +15 -0
  88. data/lib/aia/fact_asserter.rb +27 -0
  89. data/lib/aia/fzf.rb +9 -31
  90. data/lib/aia/handler_context.rb +17 -0
  91. data/lib/aia/handler_protocol.rb +19 -0
  92. data/lib/aia/history_transfer.rb +55 -0
  93. data/lib/aia/input_collector.rb +3 -3
  94. data/lib/aia/layered_orchestrator.rb +448 -0
  95. data/lib/aia/logger.rb +24 -4
  96. data/lib/aia/mcp_config_normalizer.rb +35 -0
  97. data/lib/aia/mcp_connection_manager.rb +305 -0
  98. data/lib/aia/mcp_discovery.rb +44 -0
  99. data/lib/aia/mcp_grouper.rb +33 -0
  100. data/lib/aia/mcp_utility.rb +57 -0
  101. data/lib/aia/mention_router.rb +260 -0
  102. data/lib/aia/model_alias_registry.rb +97 -0
  103. data/lib/aia/model_switch_handler.rb +100 -0
  104. data/lib/aia/network_builder.rb +155 -0
  105. data/lib/aia/network_memory_manager.rb +55 -0
  106. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  107. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  108. data/lib/aia/pipeline_orchestrator.rb +262 -0
  109. data/lib/aia/plugin_loader.rb +170 -0
  110. data/lib/aia/plugin_monitor.rb +208 -0
  111. data/lib/aia/prompt_decomposer.rb +157 -0
  112. data/lib/aia/prompt_handler.rb +19 -39
  113. data/lib/aia/robot_builder.rb +51 -0
  114. data/lib/aia/robot_factory.rb +334 -0
  115. data/lib/aia/robot_namer.rb +116 -0
  116. data/lib/aia/session.rb +83 -17
  117. data/lib/aia/session_tracker.rb +209 -0
  118. data/lib/aia/similarity_scorer.rb +39 -0
  119. data/lib/aia/skill_utils.rb +105 -1
  120. data/lib/aia/spawn_handler.rb +129 -0
  121. data/lib/aia/spawn_spec_parser.rb +65 -0
  122. data/lib/aia/special_mode_handler.rb +302 -0
  123. data/lib/aia/startup_coordinator.rb +150 -0
  124. data/lib/aia/streaming_runner.rb +169 -0
  125. data/lib/aia/system_prompt_assembler.rb +88 -0
  126. data/lib/aia/task_coordinator.rb +202 -0
  127. data/lib/aia/task_decomposer.rb +57 -0
  128. data/lib/aia/task_executor.rb +51 -0
  129. data/lib/aia/tfidf_math.rb +27 -0
  130. data/lib/aia/tool_filter/tfidf.rb +116 -0
  131. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  132. data/lib/aia/tool_filter.rb +82 -0
  133. data/lib/aia/tool_filter_registry.rb +30 -0
  134. data/lib/aia/tool_filter_strategy.rb +143 -0
  135. data/lib/aia/tool_loader.rb +210 -0
  136. data/lib/aia/tool_utility.rb +30 -0
  137. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  138. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  139. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  140. data/lib/aia/tools/task_board_tool.rb +114 -0
  141. data/lib/aia/trakflow_bridge.rb +173 -0
  142. data/lib/aia/turn_state.rb +94 -0
  143. data/lib/aia/ui_presenter.rb +166 -198
  144. data/lib/aia/utility.rb +134 -87
  145. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  146. data/lib/aia/verification_network.rb +58 -0
  147. data/lib/aia.rb +108 -63
  148. data/mkdocs.yml +1 -0
  149. metadata +179 -56
  150. data/justfile +0 -215
  151. data/lib/aia/adapter/chat_execution.rb +0 -242
  152. data/lib/aia/adapter/error_handler.rb +0 -68
  153. data/lib/aia/adapter/gem_activator.rb +0 -57
  154. data/lib/aia/adapter/mcp_connector.rb +0 -274
  155. data/lib/aia/adapter/modality_handlers.rb +0 -167
  156. data/lib/aia/adapter/model_registry.rb +0 -81
  157. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  158. data/lib/aia/adapter/provider_configurator.rb +0 -59
  159. data/lib/aia/adapter/tool_filter.rb +0 -85
  160. data/lib/aia/adapter/tool_loader.rb +0 -90
  161. data/lib/aia/chat_processor_service.rb +0 -178
  162. data/lib/aia/prompt_pipeline.rb +0 -183
  163. data/lib/aia/ruby_llm_adapter.rb +0 -95
  164. data/lib/extensions/openstruct_merge.rb +0 -48
  165. data/lib/extensions/ruby_llm/.irbrc +0 -56
  166. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  167. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  168. data/lib/refinements/string.rb +0 -16
  169. data/main.just +0 -76
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/special_mode_handler.rb
4
+ #
5
+ # Handles special execution modes triggered by directives:
6
+ # /verify — two independent answers + reconciliation
7
+ # /decompose — break into parallel sub-tasks (Async::Barrier for I/O concurrency)
8
+ # /concurrent — concurrent MCP server access
9
+ # /orchestrate — 3-tier layered orchestration (orchestrator → leads → specialists)
10
+
11
+ require 'async'
12
+
13
+ module AIA
14
+ class SpecialModeHandler
15
+ include ContentExtractor
16
+
17
+ # Special-mode dispatch table. Each row is triggered by a TurnState flag and
18
+ # runs either a bespoke method (`run:`) or, for the uniform "sub-handler
19
+ # returns content" family, the shared #run_sub_handler (`sub:`). Adding a
20
+ # mode means adding a row here plus (for a bespoke mode) its handle_* method
21
+ # — never editing #handle.
22
+ MODES = [
23
+ { flag: :force_verify, run: :handle_verification },
24
+ { flag: :force_decompose, run: :handle_decomposition },
25
+ { flag: :force_concurrent_mcp, run: :handle_concurrent_mcp },
26
+ { flag: :force_debate, sub: { handler: :debate_handler, label: 'Debate' } },
27
+ { flag: :force_delegate, sub: { handler: :delegate_handler, label: 'Delegation' } },
28
+ { flag: :force_spawn, sub: { handler: :spawn_handler, label: 'Spawn',
29
+ context: lambda { |prompt, ts|
30
+ type = ts.spawn_type
31
+ spec = ts.spawn_spec
32
+ ts.spawn_type = nil
33
+ ts.spawn_spec = nil
34
+ { prompt: prompt, specialist_type: type, spawn_spec: spec }
35
+ } } },
36
+ { flag: :force_orchestrate, run: :handle_orchestration }
37
+ ].freeze
38
+
39
+ # Memoized sub-handler accessors, used to propagate a robot swap.
40
+ HANDLERS = %i[debate_handler delegate_handler spawn_handler layered_orchestrator].freeze
41
+
42
+ def initialize(robot:, ui_presenter:, tracker:)
43
+ @robot = robot
44
+ @ui_presenter = ui_presenter
45
+ @tracker = tracker
46
+ # Handlers are instantiated on first use to avoid loading their
47
+ # dependencies (async, trakflow, etc.) unless the mode is invoked.
48
+ @debate_handler = nil
49
+ @delegate_handler = nil
50
+ @spawn_handler = nil
51
+ @layered_orchestrator = nil
52
+ end
53
+
54
+ # Update the robot reference (e.g., after a model switch).
55
+ def robot=(new_robot)
56
+ @robot = new_robot
57
+ HANDLERS.each do |name|
58
+ inst = instance_variable_get(:"@#{name}")
59
+ inst.robot = new_robot if inst
60
+ end
61
+ end
62
+
63
+ # Check TurnState flags and dispatch to the appropriate handler.
64
+ # Returns true if a special mode was handled.
65
+ #
66
+ # @param prompt [String] the user prompt
67
+ # @return [Boolean]
68
+ def handle(prompt)
69
+ turn_state = AIA.turn_state
70
+ mode = MODES.find { |m| turn_state.public_send(m[:flag]) }
71
+ return false unless mode
72
+
73
+ turn_state.public_send(:"#{mode[:flag]}=", false)
74
+ if mode[:sub]
75
+ run_sub_handler(mode[:sub], prompt, turn_state)
76
+ else
77
+ send(mode[:run], prompt)
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ # Run a "sub-handler returns content" mode: debate / delegate / spawn share
84
+ # this exact shape, differing only by handler, label, and (spawn) an extra
85
+ # context field built by spec[:context].
86
+ def run_sub_handler(spec, prompt, turn_state)
87
+ ctx_args = spec[:context] ? spec[:context].call(prompt, turn_state) : { prompt: prompt }
88
+ content = send(spec[:handler]).handle(HandlerContext.new(**ctx_args))
89
+ return false unless content
90
+
91
+ display_and_save(content)
92
+ true
93
+ rescue StandardError => e
94
+ @ui_presenter.display_info("#{spec[:label]} failed: #{e.message}. Falling back to normal mode.")
95
+ false
96
+ end
97
+
98
+ def handle_verification(prompt)
99
+ require_relative 'verification_network'
100
+ @ui_presenter.display_info("Running verification (2 independent + reconciliation)...")
101
+
102
+ network = VerificationNetwork.build(AIA.config)
103
+ result = @ui_presenter.with_spinner("Verifying") { network.run(message: prompt) }
104
+
105
+ present_result(result, prompt: prompt, ui_presenter: @ui_presenter, tracker: @tracker)
106
+ true
107
+ rescue StandardError => e
108
+ @ui_presenter.display_info("Verification failed: #{e.message}. Falling back to normal mode.")
109
+ false
110
+ end
111
+
112
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
113
+ def handle_decomposition(prompt)
114
+ require_relative 'prompt_decomposer'
115
+ total_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
116
+
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
122
+
123
+ if subtasks.empty?
124
+ @ui_presenter.display_info("Prompt cannot be meaningfully decomposed. Running normally.")
125
+ return false
126
+ end
127
+
128
+ @ui_presenter.display_info("Decomposed into #{subtasks.size} sub-tasks:")
129
+ subtasks.each_with_index { |t, i| @ui_presenter.display_info(" #{i + 1}. #{t}") }
130
+
131
+ timings = Array.new(subtasks.size, 0.0)
132
+ raw_subtasks = Array.new(subtasks.size)
133
+ wall_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
134
+
135
+ results = Sync do
136
+ barrier = Async::Barrier.new
137
+ tasks = subtasks.each_with_index.map do |task, i|
138
+ barrier.async do
139
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
140
+ @ui_presenter.display_info("Processing sub-task #{i + 1}...")
141
+ r = @robot.run(task, mcp: :inherit, tools: :inherit)
142
+ timings[i] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
143
+ raw_subtasks[i] = r
144
+ extract_content(r)
145
+ rescue => e
146
+ timings[i] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
147
+ @ui_presenter.display_info(" Sub-task #{i + 1} failed: #{e.message}")
148
+ nil
149
+ end
150
+ end
151
+ barrier.wait
152
+ tasks.map(&:wait)
153
+ end
154
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
155
+
156
+ parallel_wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_start
157
+ serial_est = timings.sum
158
+ parallel_speedup = serial_est / parallel_wall
159
+
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
+ lines = ["", "Benchmark:"]
170
+ lines << " Decompose probe : #{format('%.2f', decompose_dur)}s"
171
+ timings.each_with_index { |t, i| lines << " Sub-task #{i + 1} : #{format('%.2f', t)}s" }
172
+ lines << " Synthesis : #{format('%.2f', synth_dur)}s"
173
+ lines << " ─────────────────────────────────────────"
174
+ lines << " Sub-tasks serial est. : #{format('%.2f', serial_est)}s (sum of sub-task times)"
175
+ lines << " Sub-tasks concurrent : #{format('%.2f', parallel_wall)}s (#{format('%.1f', parallel_speedup)}x parallel speedup)"
176
+ lines << " Total flow wall time : #{format('%.2f', total_wall)}s (compare against --tokens Time for a normal run)"
177
+ @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
+ end
187
+
188
+ def handle_concurrent_mcp(prompt)
189
+ return false unless (AIA.config.mcp_servers || []).size > 1
190
+
191
+ discovery = MCPDiscovery.new
192
+ relevant = discovery.discover(AIA.config)
193
+ return false if relevant.size <= 1
194
+
195
+ grouper = MCPGrouper.new
196
+ groups = grouper.group(relevant)
197
+ return false if groups.size < 2
198
+
199
+ @ui_presenter.display_info("Running concurrent MCP across #{groups.size} server groups...")
200
+
201
+ network = RobotFactory.build_concurrent_mcp_network(AIA.config, groups)
202
+ result = @ui_presenter.with_spinner("Processing (concurrent)") { network.run(message: prompt) }
203
+ extract_content(result)
204
+
205
+ present_result(result, prompt: prompt, ui_presenter: @ui_presenter, tracker: @tracker)
206
+ true
207
+ rescue StandardError => e
208
+ @ui_presenter.display_info("Concurrent MCP failed: #{e.message}. Falling back to normal mode.")
209
+ false
210
+ end
211
+
212
+ def handle_orchestration(prompt)
213
+ @ui_presenter.display_info("Starting 3-tier layered orchestration...")
214
+
215
+ content = layered_orchestrator.handle(HandlerContext.new(robot: @robot, prompt: prompt))
216
+
217
+ if content
218
+ display_and_save(content)
219
+ else
220
+ @ui_presenter.display_separator
221
+ end
222
+
223
+ true # always consume the prompt; never fall through to normal mode
224
+ rescue StandardError => e
225
+ @ui_presenter.display_info("Orchestration failed: #{e.class}: #{e.message}")
226
+ @ui_presenter.display_separator
227
+ true
228
+ end
229
+
230
+ def debate_handler
231
+ @debate_handler ||= begin
232
+ require_relative 'debate_handler'
233
+ DebateHandler.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
234
+ end
235
+ end
236
+
237
+ def delegate_handler
238
+ @delegate_handler ||= begin
239
+ require_relative 'delegate_handler'
240
+ require_relative 'task_decomposer'
241
+ require_relative 'task_executor'
242
+ DelegateHandler.new(
243
+ robot: @robot, ui_presenter: @ui_presenter,
244
+ tracker: @tracker, task_coordinator: AIA.task_coordinator
245
+ )
246
+ end
247
+ end
248
+
249
+ def spawn_handler
250
+ @spawn_handler ||= begin
251
+ require_relative 'spawn_handler'
252
+ SpawnHandler.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
253
+ end
254
+ end
255
+
256
+ def layered_orchestrator
257
+ @layered_orchestrator ||= begin
258
+ require_relative 'layered_orchestrator'
259
+ LayeredOrchestrator.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
260
+ end
261
+ end
262
+
263
+ # Display content, write to output file, and print separator.
264
+ # Used by handlers that receive pre-extracted content from sub-handlers.
265
+ def display_and_save(content)
266
+ @ui_presenter.display_ai_response(content)
267
+ output_to_file(content)
268
+ @ui_presenter.display_separator
269
+ end
270
+
271
+ # Aggregate token counts from all sub-task raw results plus the synthesis
272
+ # result and display a combined metrics table. Only runs when --tokens is set.
273
+ def display_decompose_token_metrics(raw_subtasks, synthesis_result, total_elapsed)
274
+ return unless AIA.config.flags.tokens
275
+
276
+ total_input = 0
277
+ total_output = 0
278
+
279
+ raw_subtasks.compact.each do |r|
280
+ raw = r.respond_to?(:raw) ? r.raw : nil
281
+ next unless raw.respond_to?(:input_tokens) && raw.input_tokens
282
+ total_input += raw.input_tokens || 0
283
+ total_output += raw.output_tokens || 0
284
+ end
285
+
286
+ synth_raw = synthesis_result.respond_to?(:raw) ? synthesis_result.raw : nil
287
+ if synth_raw.respond_to?(:input_tokens) && synth_raw.input_tokens
288
+ total_input += synth_raw.input_tokens || 0
289
+ total_output += synth_raw.output_tokens || 0
290
+ end
291
+
292
+ return if total_input.zero? && total_output.zero?
293
+
294
+ @ui_presenter.display_token_metrics({
295
+ model_id: AIA.config.models.first.name,
296
+ input_tokens: total_input,
297
+ output_tokens: total_output,
298
+ elapsed: total_elapsed
299
+ })
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/startup_coordinator.rb
4
+ #
5
+ # Handles all session startup tasks: MCP connection, tool loading,
6
+ # filter initialization, task coordination, and bus attachment.
7
+ # Extracted from Session to give Session a single responsibility.
8
+
9
+ require "fileutils"
10
+ require "tty-spinner"
11
+
12
+ module AIA
13
+ class StartupCoordinator
14
+ attr_reader :filters, :mcp_manager
15
+
16
+ def initialize(robot:, ui_presenter:)
17
+ @robot = robot
18
+ @ui = ui_presenter
19
+ @filters = {}
20
+ @mcp_manager = nil
21
+ end
22
+
23
+ # Run all startup coordination tasks.
24
+ #
25
+ # @param config [AIA::Config]
26
+ def run(config)
27
+ connect_mcp_servers(config)
28
+
29
+ tools = all_available_tools(config)
30
+
31
+ spinner = TTY::Spinner.new(" [:spinner] Initializing tool filters...", format: :dots)
32
+ spinner.auto_spin
33
+ @filters = ToolFilterRegistry.build_from_config(config, tools)
34
+ spinner.success
35
+
36
+ initialize_task_coordinator if trakflow_available?
37
+
38
+ attach_bus_if_network
39
+ end
40
+
41
+ private
42
+
43
+ # Connect ALL configured MCP servers eagerly at startup, then absorb any
44
+ # MCP clients registered via --require (e.g., shared_tools/mcp/* files).
45
+ # Uses MCPDiscovery to determine which servers to connect (respects
46
+ # --mcp-use, --mcp-skip, and KBS mcp_activate decisions), then normalizes
47
+ # each server config via MCPConfigNormalizer before handing off to
48
+ # MCPConnectionManager.
49
+ def connect_mcp_servers(config)
50
+ return if config.flags.no_mcp
51
+
52
+ discovered = MCPDiscovery.new.discover(config)
53
+ validate_mcp_use_names(config, discovered) if Array(config.mcp_use).any?
54
+
55
+ # Prefer the robot's pre-built mcp_config (already normalized) when
56
+ # present, but always apply --mcp-skip so that flag is honoured.
57
+ # Fall back to the MCPDiscovery result (which includes both filters).
58
+ servers = if @robot.respond_to?(:mcp_config) && @robot.mcp_config.is_a?(Array)
59
+ skip_list = Array(config.mcp_skip)
60
+ @robot.mcp_config.reject { |s| skip_list.include?(AIA::Utility.server_name(s)) }
61
+ else
62
+ discovered.map { |s| MCPConfigNormalizer.normalize(s) }
63
+ end
64
+
65
+ @mcp_manager = MCPConnectionManager.new
66
+ @mcp_manager.connect_all(servers) if servers.is_a?(Array) && servers.any?
67
+ @mcp_manager.absorb_ruby_llm_mcp_clients
68
+ return unless @mcp_manager.connected?
69
+
70
+ @mcp_manager.update_config
71
+ @mcp_manager.inject_into(@robot) if @mcp_manager.any_tools?
72
+ end
73
+
74
+ # Initialize TaskCoordinator, auto-creating the TrakFlow project if needed.
75
+ def initialize_task_coordinator
76
+ require_relative 'task_coordinator'
77
+ require_relative 'trakflow_bridge'
78
+ require_relative 'tools/task_board_tool'
79
+ require_relative 'tools/delegate_to_foreman_tool'
80
+
81
+ ensure_trakflow_initialized unless TrakFlow.initialized?
82
+
83
+ AIA.task_coordinator = TaskCoordinator.new
84
+ rescue StandardError => e
85
+ AIA.debug_warn("TaskCoordinator initialization failed (non-fatal): #{e.message}", exc: e)
86
+ end
87
+
88
+ # Return true if the trak_flow gem is available; false if it cannot be loaded.
89
+ def trakflow_available?
90
+ defined?(TrakFlow) || begin
91
+ require 'trak_flow'
92
+ true
93
+ rescue LoadError
94
+ false
95
+ end
96
+ end
97
+
98
+ # Bootstrap a TrakFlow project so the task board is always available.
99
+ # Uses the default database path (~/.config/trak_flow/tf.db).
100
+ def ensure_trakflow_initialized
101
+ trakflow_dir = TrakFlow.trak_flow_dir
102
+ FileUtils.mkdir_p(trakflow_dir)
103
+
104
+ db = TrakFlow::Storage::Database.new
105
+ db.connect
106
+
107
+ TrakFlow.reset_root!
108
+ end
109
+
110
+ # Attach a shared TypedBus to multi-model networks.
111
+ def attach_bus_if_network
112
+ return unless @robot.network?
113
+
114
+ RobotFactory.attach_bus(@robot)
115
+ rescue StandardError => e
116
+ AIA.debug_warn("Bus attachment failed (non-fatal): #{e.message}", exc: e)
117
+ end
118
+
119
+ # Warn when --mcp-use names don't match any configured server.
120
+ # Helps users catch typos before a silent empty-tools session.
121
+ def validate_mcp_use_names(config, discovered_servers)
122
+ requested = Array(config.mcp_use)
123
+ available = Array(config.mcp_servers).map { |s| AIA::Utility.server_name(s) }
124
+ found = discovered_servers.map { |s| AIA::Utility.server_name(s) }
125
+ missing = requested - found
126
+
127
+ return if missing.empty?
128
+
129
+ $stderr.puts "WARNING: --mcp-use specified server(s) not found in config: #{missing.join(', ')}"
130
+ $stderr.puts " Available servers: #{available.join(', ')}" if available.any?
131
+ end
132
+
133
+ # Collect all tools available to the robot: local + MCP.
134
+ def all_available_tools(config)
135
+ local = Array(config.loaded_tools)
136
+ mcp = collect_mcp_tools
137
+ local + mcp
138
+ end
139
+
140
+ # Retrieve MCP tools from the connection manager.
141
+ # MCPConnectionManager builds RobotLab::Tool instances via connect_one and
142
+ # absorb_ruby_llm_mcp_clients — those are what get injected into the robot
143
+ # and what robot_lab filters by name. RubyLLM::MCP.clients is a different
144
+ # registry and does not contain the tools connected here.
145
+ def collect_mcp_tools
146
+ return [] unless @mcp_manager
147
+ @mcp_manager.connected_tools
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/streaming_runner.rb
4
+ #
5
+ # Encapsulates the streaming execution pattern used across ChatLoop,
6
+ # MentionRouter, and expert routing. Manages spinner lifecycle and
7
+ # streaming callback for any robot.
8
+
9
+ require "tty-spinner"
10
+
11
+ module AIA
12
+ class StreamingRunner
13
+ # Hard ceiling on tools sent to the provider. OpenAI and Anthropic both
14
+ # reject tool arrays longer than 128. Overridable via config.max_tools.
15
+ DEFAULT_MAX_TOOLS = 128
16
+
17
+ def initialize
18
+ @spinner = TTY::Spinner.new("[:spinner] Processing...", format: :bouncing_ball)
19
+ end
20
+
21
+ # Run a robot with streaming output.
22
+ # Spinner shows until the first chunk arrives, then stops and
23
+ # chunks are printed directly to stdout.
24
+ #
25
+ # @param robot [RobotLab::Robot, RobotLab::Network] the robot to run
26
+ # @param prompt [String] the prompt to send
27
+ # @param header [String] text printed before the first streamed chunk
28
+ # @param spinner_message [String] spinner label
29
+ # @param tools [Array<String>, nil] tool names to allow for this turn (nil = all)
30
+ # @return [Array(Object, String, Float)] [result, streamed_content_or_nil, elapsed_seconds]
31
+ def run(robot, prompt, header: "\nAI:\n ", spinner_message: "Processing...", tools: nil)
32
+ @spinner.reset
33
+ @spinner.update(title: spinner_message)
34
+ @spinner.auto_spin
35
+ streamed = []
36
+ header_printed = false
37
+ in_think_block = false # tracks position inside a <think>...</think> span across chunks
38
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+
40
+ streaming_block = proc do |chunk|
41
+ text = chunk.respond_to?(:content) ? chunk.content.to_s : chunk.to_s
42
+ next if text.empty?
43
+
44
+ # qwen3 and similar local reasoning models embed thinking inline in the
45
+ # content stream wrapped in <think>...</think> tags. Filter them out
46
+ # unless --thinking is on. Tag boundaries may split across chunks, so
47
+ # in_think_block persists across calls via the closure.
48
+ unless show_thinking?
49
+ text, in_think_block = filter_thinking(text, in_think_block)
50
+ next if text.empty?
51
+ end
52
+
53
+ unless header_printed
54
+ @spinner.stop
55
+ print header
56
+ header_printed = true
57
+ end
58
+
59
+ streamed << text
60
+ $stdout.print(text)
61
+ end
62
+
63
+ tools_param = resolve_tools_param(tools, robot)
64
+
65
+ begin
66
+ result = robot.run(prompt, mcp: :inherit, tools: tools_param, &streaming_block)
67
+ rescue Exception # rubocop:disable Lint/RescueException
68
+ @spinner.stop unless header_printed
69
+ raise
70
+ end
71
+
72
+ @spinner.stop unless header_printed
73
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
74
+ content = streamed.empty? ? nil : streamed.join
75
+ [result, content, elapsed]
76
+ end
77
+
78
+ private
79
+
80
+ # Translate the resolved tool list into robot_lab's ToolConfig vocabulary,
81
+ # then clamp it to the provider's limit:
82
+ # nil -> :inherit (no filter active / filter errored — use the full set)
83
+ # [] -> :none (filter ran and found nothing relevant — send no tools)
84
+ # names -> names (filtered subset; ToolConfig.filter_tools applies them)
85
+ # Distinguishing [] from nil is what lets a "nothing relevant" turn send zero
86
+ # tools instead of the whole build-time set.
87
+ def resolve_tools_param(tools, robot)
88
+ param =
89
+ if tools.nil?
90
+ :inherit
91
+ elsif tools.empty?
92
+ :none
93
+ else
94
+ tools
95
+ end
96
+
97
+ enforce_tool_cap(param, robot)
98
+ end
99
+
100
+ # Clamp the effective tool list to the provider's maximum. Returns the
101
+ # value unchanged when it's already within budget (including :none, and
102
+ # :inherit when the robot's own tool count is under the cap). When over,
103
+ # expands :inherit to the robot's tool names, trims to the cap, and warns —
104
+ # a reduced tool set beats a failed turn.
105
+ def enforce_tool_cap(tools_param, robot)
106
+ return tools_param if tools_param == :none
107
+
108
+ max = max_tools
109
+ names = tools_param == :inherit ? robot_tool_names(robot) : tools_param
110
+ return tools_param if names.size <= max
111
+
112
+ dropped = names.size - max
113
+ $stderr.puts(
114
+ "⚠ Tool list (#{names.size}) exceeds the provider limit of #{max}; " \
115
+ "sending #{max}, dropping #{dropped}. Enable --auto-tool-filter or reduce " \
116
+ "the available tools to control which ones are sent."
117
+ )
118
+ names.first(max)
119
+ end
120
+
121
+ # The provider tool cap, from config.max_tools when set, else the default.
122
+ def max_tools
123
+ configured = AIA.respond_to?(:config) && AIA.config.respond_to?(:max_tools) ? AIA.config.max_tools : nil
124
+ configured&.positive? ? configured : DEFAULT_MAX_TOOLS
125
+ end
126
+
127
+ # The robot's full set of tool names (local + MCP). Used only to size and
128
+ # trim an over-limit :inherit set.
129
+ def robot_tool_names(robot)
130
+ local = robot.respond_to?(:local_tools) ? Array(robot.local_tools) : []
131
+ mcp = robot.respond_to?(:mcp_tools) ? Array(robot.mcp_tools) : []
132
+ (local + mcp).map { |t| t.respond_to?(:name) ? t.name : t.class.name }
133
+ end
134
+
135
+ def show_thinking?
136
+ AIA.config&.flags&.thinking
137
+ end
138
+
139
+ # Strip <think>...</think> spans from a streaming chunk, handling the case
140
+ # where tag boundaries fall between chunk deliveries.
141
+ #
142
+ # @param text [String] raw chunk text
143
+ # @param in_think_block [Boolean] whether a <think> tag is currently open
144
+ # @return [Array(String, Boolean)] [filtered_text, updated_in_think_block]
145
+ def filter_thinking(text, in_think_block)
146
+ output = +''
147
+
148
+ until text.empty?
149
+ if in_think_block
150
+ if (idx = text.index('</think>'))
151
+ in_think_block = false
152
+ text = text[(idx + '</think>'.length)..]
153
+ else
154
+ break # entire remaining chunk is thinking — discard
155
+ end
156
+ elsif (idx = text.index('<think>'))
157
+ output << text[0...idx]
158
+ in_think_block = true
159
+ text = text[(idx + '<think>'.length)..]
160
+ else
161
+ output << text
162
+ break
163
+ end
164
+ end
165
+
166
+ [output, in_think_block]
167
+ end
168
+ end
169
+ end