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.
- 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 +259 -50
- 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 +18 -6
- data/examples/context/tech_stack.md +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 +304 -164
- 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 +108 -142
- 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 -178
- 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,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
|