aia 1.1.1 → 2.0.0.0.pre.beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +9 -1
- data/.loki +11 -0
- data/.reek.yml +160 -0
- data/.rubocop.yml +116 -0
- data/.rubocop_strict.yml +15 -0
- data/.version +1 -1
- data/CHANGELOG.md +269 -55
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +267 -239
- data/Rakefile +5 -5
- data/_typos.toml +10 -0
- data/aia.gemspec +92 -0
- data/architecture_review.md +314 -0
- data/bin/aia +16 -0
- data/config/aia.yml +13 -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 +263 -167
- data/lib/aia/config/cli_parser.rb +217 -145
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +52 -51
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +171 -216
- data/lib/aia/config.rb +111 -145
- data/lib/aia/content_extractor.rb +155 -0
- data/lib/aia/cost_calculator.rb +39 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +174 -0
- data/lib/aia/delegate_handler.rb +116 -0
- data/lib/aia/directive.rb +43 -26
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +214 -60
- data/lib/aia/directives/context_directives.rb +67 -52
- data/lib/aia/directives/execution_directives.rb +141 -4
- data/lib/aia/directives/model_directives.rb +163 -141
- data/lib/aia/directives/trakflow_directives.rb +62 -0
- data/lib/aia/directives/utility_directives.rb +227 -30
- data/lib/aia/directives/web_and_file_directives.rb +126 -77
- 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 +471 -0
- data/lib/aia/logger.rb +45 -25
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +315 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_server_config.rb +30 -0
- data/lib/aia/mcp_utility.rb +60 -0
- data/lib/aia/mention_router.rb +217 -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 +160 -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 +272 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +211 -0
- data/lib/aia/prompt_decomposer.rb +159 -0
- data/lib/aia/prompt_handler.rb +56 -82
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +338 -0
- data/lib/aia/robot_namer.rb +110 -0
- data/lib/aia/session.rb +87 -17
- data/lib/aia/session_tracker.rb +207 -0
- data/lib/aia/similarity_scorer.rb +41 -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 +322 -0
- data/lib/aia/speech.rb +67 -0
- data/lib/aia/startup_coordinator.rb +151 -0
- data/lib/aia/streaming_runner.rb +172 -0
- data/lib/aia/system_prompt_assembler.rb +92 -0
- data/lib/aia/task_coordinator.rb +207 -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/timing.rb +15 -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 +83 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +146 -0
- data/lib/aia/tool_introspection.rb +17 -0
- data/lib/aia/tool_loader.rb +216 -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 +115 -0
- data/lib/aia/trakflow_bridge.rb +175 -0
- data/lib/aia/turn_state.rb +95 -0
- data/lib/aia/ui_presenter.rb +182 -206
- data/lib/aia/utility.rb +136 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
- data/lib/aia/verification_network.rb +57 -0
- data/lib/aia.rb +124 -63
- data/mkdocs.yml +1 -0
- metadata +187 -58
- 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,322 @@
|
|
|
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
|
+
# :reek:TooManyStatements -- benchmark-instrumented pipeline: decompose, concurrent fan-out, synthesis, reporting
|
|
113
|
+
def handle_decomposition(prompt)
|
|
114
|
+
require_relative 'prompt_decomposer'
|
|
115
|
+
total_t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
116
|
+
|
|
117
|
+
@ui_presenter.display_info("Decomposing prompt into sub-tasks...")
|
|
118
|
+
decomposer = PromptDecomposer.new(@robot)
|
|
119
|
+
subtasks, decompose_dur = Timing.timed { decomposer.decompose(prompt) }
|
|
120
|
+
|
|
121
|
+
if subtasks.empty?
|
|
122
|
+
@ui_presenter.display_info("Prompt cannot be meaningfully decomposed. Running normally.")
|
|
123
|
+
return false
|
|
124
|
+
end
|
|
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)
|
|
150
|
+
@ui_presenter.display_info("Decomposed into #{subtasks.size} sub-tasks:")
|
|
151
|
+
subtasks.each_with_index { |t, i| @ui_presenter.display_info(" #{i + 1}. #{t}") }
|
|
152
|
+
end
|
|
153
|
+
|
|
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
|
|
162
|
+
|
|
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
|
|
169
|
+
barrier = Async::Barrier.new
|
|
170
|
+
tasks = subtasks.each_with_index.map do |task, i|
|
|
171
|
+
barrier.async do
|
|
172
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
173
|
+
@ui_presenter.display_info("Processing sub-task #{i + 1}...")
|
|
174
|
+
r = @robot.run(task, mcp: :inherit, tools: :inherit)
|
|
175
|
+
timings[i] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
176
|
+
raw_subtasks[i] = r
|
|
177
|
+
extract_content(r)
|
|
178
|
+
rescue => e
|
|
179
|
+
timings[i] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
180
|
+
@ui_presenter.display_info(" Sub-task #{i + 1} failed: #{e.message}")
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
barrier.wait
|
|
185
|
+
tasks.map(&:wait)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Print the decomposition benchmark table.
|
|
190
|
+
def display_decompose_benchmark(decompose_dur:, timings:, synth_dur:, parallel_wall:, total_wall:)
|
|
191
|
+
serial_est = timings.sum
|
|
192
|
+
parallel_speedup = serial_est / parallel_wall
|
|
193
|
+
|
|
194
|
+
lines = ["", "Benchmark:"]
|
|
195
|
+
lines << " Decompose probe : #{format('%.2f', decompose_dur)}s"
|
|
196
|
+
timings.each_with_index { |t, i| lines << " Sub-task #{i + 1} : #{format('%.2f', t)}s" }
|
|
197
|
+
lines << " Synthesis : #{format('%.2f', synth_dur)}s"
|
|
198
|
+
lines << " ─────────────────────────────────────────"
|
|
199
|
+
lines << " Sub-tasks serial est. : #{format('%.2f', serial_est)}s (sum of sub-task times)"
|
|
200
|
+
lines << " Sub-tasks concurrent : #{format('%.2f', parallel_wall)}s (#{format('%.1f', parallel_speedup)}x parallel speedup)"
|
|
201
|
+
lines << " Total flow wall time : #{format('%.2f', total_wall)}s (compare against --tokens Time for a normal run)"
|
|
202
|
+
@ui_presenter.display_info(lines.join("\n"))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# :reek:TooManyStatements -- ordered eligibility gates (server count, discovery, grouping) before the network run
|
|
206
|
+
def handle_concurrent_mcp(prompt)
|
|
207
|
+
cfg = AIA.config
|
|
208
|
+
return false unless (cfg.mcp_servers || []).size > 1
|
|
209
|
+
|
|
210
|
+
discovery = MCPDiscovery.new
|
|
211
|
+
relevant = discovery.discover(cfg)
|
|
212
|
+
return false if relevant.size <= 1
|
|
213
|
+
|
|
214
|
+
grouper = MCPGrouper.new
|
|
215
|
+
groups = grouper.group(relevant)
|
|
216
|
+
return false if groups.size < 2
|
|
217
|
+
|
|
218
|
+
@ui_presenter.display_info("Running concurrent MCP across #{groups.size} server groups...")
|
|
219
|
+
|
|
220
|
+
network = RobotFactory.build_concurrent_mcp_network(cfg, groups)
|
|
221
|
+
result = @ui_presenter.with_spinner("Processing (concurrent)") { network.run(message: prompt) }
|
|
222
|
+
extract_content(result)
|
|
223
|
+
|
|
224
|
+
present_result(result, prompt: prompt, ui_presenter: @ui_presenter, tracker: @tracker)
|
|
225
|
+
true
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
@ui_presenter.display_info("Concurrent MCP failed: #{e.message}. Falling back to normal mode.")
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def handle_orchestration(prompt)
|
|
232
|
+
@ui_presenter.display_info("Starting 3-tier layered orchestration...")
|
|
233
|
+
|
|
234
|
+
content = layered_orchestrator.handle(HandlerContext.new(robot: @robot, prompt: prompt))
|
|
235
|
+
|
|
236
|
+
if content
|
|
237
|
+
display_and_save(content)
|
|
238
|
+
else
|
|
239
|
+
@ui_presenter.display_separator
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
true # always consume the prompt; never fall through to normal mode
|
|
243
|
+
rescue StandardError => e
|
|
244
|
+
@ui_presenter.display_info("Orchestration failed: #{e.class}: #{e.message}")
|
|
245
|
+
@ui_presenter.display_separator
|
|
246
|
+
true
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def debate_handler
|
|
250
|
+
@debate_handler ||= begin
|
|
251
|
+
require_relative 'debate_handler'
|
|
252
|
+
DebateHandler.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def delegate_handler
|
|
257
|
+
@delegate_handler ||= begin
|
|
258
|
+
require_relative 'delegate_handler'
|
|
259
|
+
require_relative 'task_decomposer'
|
|
260
|
+
require_relative 'task_executor'
|
|
261
|
+
DelegateHandler.new(
|
|
262
|
+
robot: @robot, ui_presenter: @ui_presenter,
|
|
263
|
+
tracker: @tracker, task_coordinator: AIA.task_coordinator
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def spawn_handler
|
|
269
|
+
@spawn_handler ||= begin
|
|
270
|
+
require_relative 'spawn_handler'
|
|
271
|
+
SpawnHandler.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def layered_orchestrator
|
|
276
|
+
@layered_orchestrator ||= begin
|
|
277
|
+
require_relative 'layered_orchestrator'
|
|
278
|
+
LayeredOrchestrator.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Display content, write to output file, and print separator.
|
|
283
|
+
# Used by handlers that receive pre-extracted content from sub-handlers.
|
|
284
|
+
def display_and_save(content)
|
|
285
|
+
@ui_presenter.display_ai_response(content)
|
|
286
|
+
output_to_file(content)
|
|
287
|
+
@ui_presenter.display_separator
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Aggregate token counts from all sub-task raw results plus the synthesis
|
|
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
|
|
293
|
+
def display_decompose_token_metrics(raw_subtasks, synthesis_result, total_elapsed)
|
|
294
|
+
return unless AIA.config.flags.tokens
|
|
295
|
+
|
|
296
|
+
total_input = 0
|
|
297
|
+
total_output = 0
|
|
298
|
+
|
|
299
|
+
raw_subtasks.compact.each do |r|
|
|
300
|
+
raw = r.respond_to?(:raw) ? r.raw : nil
|
|
301
|
+
next unless raw.respond_to?(:input_tokens) && raw.input_tokens
|
|
302
|
+
total_input += raw.input_tokens || 0
|
|
303
|
+
total_output += raw.output_tokens || 0
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
synth_raw = synthesis_result.respond_to?(:raw) ? synthesis_result.raw : nil
|
|
307
|
+
if synth_raw.respond_to?(:input_tokens) && synth_raw.input_tokens
|
|
308
|
+
total_input += synth_raw.input_tokens || 0
|
|
309
|
+
total_output += synth_raw.output_tokens || 0
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
return if total_input.zero? && total_output.zero?
|
|
313
|
+
|
|
314
|
+
@ui_presenter.display_token_metrics({
|
|
315
|
+
model_id: AIA.config.models.first.name,
|
|
316
|
+
input_tokens: total_input,
|
|
317
|
+
output_tokens: total_output,
|
|
318
|
+
elapsed: total_elapsed
|
|
319
|
+
})
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
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
|
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
# :reek:TooManyStatements -- MCP wiring sequence: discovery, validation, normalize fallback, connect, absorb, inject
|
|
50
|
+
def connect_mcp_servers(config)
|
|
51
|
+
return if config.flags.no_mcp
|
|
52
|
+
|
|
53
|
+
discovered = MCPDiscovery.new.discover(config)
|
|
54
|
+
validate_mcp_use_names(config, discovered) if Array(config.mcp_use).any?
|
|
55
|
+
|
|
56
|
+
# Prefer the robot's pre-built mcp_config (already normalized) when
|
|
57
|
+
# present, but always apply --mcp-skip so that flag is honoured.
|
|
58
|
+
# Fall back to the MCPDiscovery result (which includes both filters).
|
|
59
|
+
servers = if @robot.respond_to?(:mcp_config) && @robot.mcp_config.is_a?(Array)
|
|
60
|
+
skip_list = Array(config.mcp_skip)
|
|
61
|
+
@robot.mcp_config.reject { |s| skip_list.include?(AIA::Utility.server_name(s)) }
|
|
62
|
+
else
|
|
63
|
+
discovered.map { |s| MCPConfigNormalizer.normalize(s) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@mcp_manager = MCPConnectionManager.new
|
|
67
|
+
@mcp_manager.connect_all(servers) if servers.is_a?(Array) && servers.any?
|
|
68
|
+
@mcp_manager.absorb_ruby_llm_mcp_clients
|
|
69
|
+
return unless @mcp_manager.connected?
|
|
70
|
+
|
|
71
|
+
@mcp_manager.update_config
|
|
72
|
+
@mcp_manager.inject_into(@robot) if @mcp_manager.any_tools?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Initialize TaskCoordinator, auto-creating the TrakFlow project if needed.
|
|
76
|
+
def initialize_task_coordinator
|
|
77
|
+
require_relative 'task_coordinator'
|
|
78
|
+
require_relative 'trakflow_bridge'
|
|
79
|
+
require_relative 'tools/task_board_tool'
|
|
80
|
+
require_relative 'tools/delegate_to_foreman_tool'
|
|
81
|
+
|
|
82
|
+
ensure_trakflow_initialized unless TrakFlow.initialized?
|
|
83
|
+
|
|
84
|
+
AIA.task_coordinator = TaskCoordinator.new
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
AIA.debug_warn("TaskCoordinator initialization failed (non-fatal): #{e.message}", exc: e)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Return true if the trak_flow gem is available; false if it cannot be loaded.
|
|
90
|
+
def trakflow_available?
|
|
91
|
+
defined?(TrakFlow) || begin
|
|
92
|
+
require 'trak_flow'
|
|
93
|
+
true
|
|
94
|
+
rescue LoadError
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Bootstrap a TrakFlow project so the task board is always available.
|
|
100
|
+
# Uses the default database path (~/.config/trak_flow/tf.db).
|
|
101
|
+
def ensure_trakflow_initialized
|
|
102
|
+
trakflow_dir = TrakFlow.trak_flow_dir
|
|
103
|
+
FileUtils.mkdir_p(trakflow_dir)
|
|
104
|
+
|
|
105
|
+
db = TrakFlow::Storage::Database.new
|
|
106
|
+
db.connect
|
|
107
|
+
|
|
108
|
+
TrakFlow.reset_root!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Attach a shared TypedBus to multi-model networks.
|
|
112
|
+
def attach_bus_if_network
|
|
113
|
+
return unless @robot.network?
|
|
114
|
+
|
|
115
|
+
RobotFactory.attach_bus(@robot)
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
AIA.debug_warn("Bus attachment failed (non-fatal): #{e.message}", exc: e)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Warn when --mcp-use names don't match any configured server.
|
|
121
|
+
# Helps users catch typos before a silent empty-tools session.
|
|
122
|
+
def validate_mcp_use_names(config, discovered_servers)
|
|
123
|
+
requested = Array(config.mcp_use)
|
|
124
|
+
available = Array(config.mcp_servers).map { |s| AIA::Utility.server_name(s) }
|
|
125
|
+
found = discovered_servers.map { |s| AIA::Utility.server_name(s) }
|
|
126
|
+
missing = requested - found
|
|
127
|
+
|
|
128
|
+
return if missing.empty?
|
|
129
|
+
|
|
130
|
+
$stderr.puts "WARNING: --mcp-use specified server(s) not found in config: #{missing.join(', ')}"
|
|
131
|
+
$stderr.puts " Available servers: #{available.join(', ')}" if available.any?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Collect all tools available to the robot: local + MCP.
|
|
135
|
+
def all_available_tools(config)
|
|
136
|
+
local = Array(config.loaded_tools)
|
|
137
|
+
mcp = collect_mcp_tools
|
|
138
|
+
local + mcp
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Retrieve MCP tools from the connection manager.
|
|
142
|
+
# MCPConnectionManager builds RobotLab::Tool instances via connect_one and
|
|
143
|
+
# absorb_ruby_llm_mcp_clients — those are what get injected into the robot
|
|
144
|
+
# and what robot_lab filters by name. RubyLLM::MCP.clients is a different
|
|
145
|
+
# registry and does not contain the tools connected here.
|
|
146
|
+
def collect_mcp_tools
|
|
147
|
+
return [] unless @mcp_manager
|
|
148
|
+
@mcp_manager.connected_tools
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|