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,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mention_router.rb
|
|
4
|
+
#
|
|
5
|
+
# Handles @mention routing in multi-model networks.
|
|
6
|
+
# Scans prompts for @name tokens, matches them to robots in the
|
|
7
|
+
# network, and sends the prompt only to mentioned robots.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class MentionRouter
|
|
11
|
+
include ContentExtractor
|
|
12
|
+
include HandlerProtocol
|
|
13
|
+
include Speech
|
|
14
|
+
|
|
15
|
+
# Reserved @mention that broadcasts the message to every crew member.
|
|
16
|
+
BROADCAST_TOKEN = 'crew'
|
|
17
|
+
|
|
18
|
+
def initialize(ui_presenter:, tracker:, streaming_runner:)
|
|
19
|
+
@ui_presenter = ui_presenter
|
|
20
|
+
@tracker = tracker
|
|
21
|
+
@streaming_runner = streaming_runner
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Scan prompt for @mentions and route to matching robots.
|
|
25
|
+
# Returns true if mentions were handled, false otherwise.
|
|
26
|
+
#
|
|
27
|
+
# @param context [HandlerContext] — reads context.robot and context.prompt
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
# :reek:TooManyStatements -- mention routing: scan, resolve, report unknowns, dispatch; one decision chain
|
|
30
|
+
def handle(context) # rubocop:disable Naming/PredicateMethod
|
|
31
|
+
robot = context.robot
|
|
32
|
+
prompt = context.prompt
|
|
33
|
+
return false unless robot.network?
|
|
34
|
+
|
|
35
|
+
mention_tokens = prompt.scan(/@(\w+)/i).flatten
|
|
36
|
+
return false if mention_tokens.empty?
|
|
37
|
+
|
|
38
|
+
all_robots = robot.crew
|
|
39
|
+
matched = resolve_mentions(mention_tokens, all_robots)
|
|
40
|
+
|
|
41
|
+
if matched.empty?
|
|
42
|
+
report_unknown_mentions(mention_tokens, all_robots)
|
|
43
|
+
return true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
clean_prompt = strip_addressing_prefix(prompt)
|
|
47
|
+
|
|
48
|
+
report_unknown_mentions(mention_tokens, all_robots)
|
|
49
|
+
run_mentioned_robots(matched, clean_prompt, concurrent: leading_address_only?(prompt))
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Remove only the leading run of @mentions (the addressing prefix), so
|
|
56
|
+
# "@alice summarize this" becomes "summarize this" while @mentions inside the
|
|
57
|
+
# message body — "hello @hemie have you met @joker" — are preserved as
|
|
58
|
+
# content and reach the routed robots intact.
|
|
59
|
+
def strip_addressing_prefix(prompt)
|
|
60
|
+
prompt.sub(/\A(?:\s*@\w+)+\s*/i, '').strip
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# True when every @mention is part of the leading address — i.e. nothing but
|
|
64
|
+
# @mentions before the message — so it's a broadcast to its addressees and
|
|
65
|
+
# runs concurrently. A @mention woven into the body makes it sequential.
|
|
66
|
+
def leading_address_only?(prompt)
|
|
67
|
+
!strip_addressing_prefix(prompt).match?(/@\w+/)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Resolve @mention tokens to robots. The reserved @crew token broadcasts to
|
|
71
|
+
# the entire crew; otherwise each token is matched to a robot by name.
|
|
72
|
+
#
|
|
73
|
+
# @return [Array<Robot>]
|
|
74
|
+
def resolve_mentions(tokens, all_robots)
|
|
75
|
+
return all_robots if tokens.any? { |token| token.casecmp?(BROADCAST_TOKEN) }
|
|
76
|
+
|
|
77
|
+
tokens.filter_map { |token| all_robots.find { |r| r.name.casecmp?(token) } }.uniq(&:name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def report_unknown_mentions(mention_tokens, all_robots)
|
|
81
|
+
known_names = all_robots.map { |r| r.name.downcase }
|
|
82
|
+
unknown = mention_tokens.reject { |t| t.casecmp?(BROADCAST_TOKEN) || known_names.include?(t.downcase) }
|
|
83
|
+
return unless unknown.any?
|
|
84
|
+
|
|
85
|
+
available = all_robots.map(&:name).join(', ')
|
|
86
|
+
unknown.each do |name|
|
|
87
|
+
@ui_presenter.display_info("Unknown robot: @#{name} (available: #{available})")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def run_mentioned_robots(robots, prompt, concurrent:)
|
|
92
|
+
if robots.size == 1
|
|
93
|
+
run_streamed(robots.first, prompt)
|
|
94
|
+
elsif concurrent
|
|
95
|
+
run_concurrently(robots, prompt)
|
|
96
|
+
else
|
|
97
|
+
run_sequentially(robots, prompt)
|
|
98
|
+
end
|
|
99
|
+
@ui_presenter.display_separator
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Robots mentioned in the body run one at a time, each streaming its reply.
|
|
103
|
+
# After each reply, it is injected into every *other* member's conversation
|
|
104
|
+
# so later robots build on it and earlier ones carry it forward — the
|
|
105
|
+
# sequential pipeline becomes a shared, accumulating conversation.
|
|
106
|
+
def run_sequentially(robots, prompt)
|
|
107
|
+
robots.each do |bot|
|
|
108
|
+
content = run_streamed(bot, prompt)
|
|
109
|
+
share_with_peers(robots, bot, content) if content
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Add one robot's reply to every other pipeline member's chat history.
|
|
114
|
+
def share_with_peers(robots, author, content)
|
|
115
|
+
note = "[#{author.name}]: #{content}"
|
|
116
|
+
(robots - [author]).each do |peer|
|
|
117
|
+
next unless peer.respond_to?(:chat) && peer.chat
|
|
118
|
+
|
|
119
|
+
peer.chat.add_message({ role: :user, content: note })
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A single addressed robot streams its reply token-by-token.
|
|
124
|
+
def run_streamed(bot, prompt)
|
|
125
|
+
name = bot.name
|
|
126
|
+
result, streamed_content, elapsed = @streaming_runner.run(
|
|
127
|
+
bot, prompt,
|
|
128
|
+
header: "\nAI (#{name}):\n ",
|
|
129
|
+
spinner_message: "#{name} processing..."
|
|
130
|
+
)
|
|
131
|
+
content = streamed_content || extract_content(result)
|
|
132
|
+
|
|
133
|
+
if streamed_content
|
|
134
|
+
puts
|
|
135
|
+
@ui_presenter.display_info("(#{format_duration(elapsed)})")
|
|
136
|
+
else
|
|
137
|
+
@ui_presenter.display_ai_response(reply_block(bot, content, elapsed))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
finalize_reply(bot, prompt, result, content, elapsed)
|
|
141
|
+
content
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
@ui_presenter.display_info("Error from #{name}: #{e.class}: #{e.message}")
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A broadcast to multiple robots runs them concurrently — no token streaming,
|
|
148
|
+
# which would interleave on one console. Each robot runs in its own thread and
|
|
149
|
+
# pushes its result to a queue; the main thread renders each reply as it lands
|
|
150
|
+
# (finish order), so wall-clock is the slowest robot, not the sum. Rendering
|
|
151
|
+
# stays single-threaded, so display/tracking are never touched concurrently.
|
|
152
|
+
def run_concurrently(robots, prompt)
|
|
153
|
+
@ui_presenter.display_info("Broadcasting to #{robots.size} robots concurrently…")
|
|
154
|
+
|
|
155
|
+
done = Queue.new
|
|
156
|
+
robots.each { |bot| Thread.new { done << run_member(bot, prompt) } }
|
|
157
|
+
robots.size.times { render_member(prompt, *done.pop) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Run one crew member to completion (off the streaming path), in a worker
|
|
161
|
+
# thread. Returns [bot, result, elapsed, error] — no shared state touched.
|
|
162
|
+
# :reek:DuplicateMethodCall -- each clock_gettime must read the clock at that moment (start, success end, failure end)
|
|
163
|
+
def run_member(bot, prompt)
|
|
164
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
165
|
+
result = bot.run(prompt, mcp: :inherit, tools: :inherit)
|
|
166
|
+
[bot, result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, nil]
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
[bot, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, e]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Render one crew member's reply on the main thread.
|
|
172
|
+
def render_member(prompt, bot, result, elapsed, error)
|
|
173
|
+
if error
|
|
174
|
+
@ui_presenter.display_info("Error from #{bot.name}: #{error.class}: #{error.message}")
|
|
175
|
+
return
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
content = extract_content(result)
|
|
179
|
+
@ui_presenter.display_ai_response(reply_block(bot, content, elapsed))
|
|
180
|
+
finalize_reply(bot, prompt, result, content, elapsed)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def reply_block(bot, content, elapsed)
|
|
184
|
+
"**#{bot.name}** [#{bot.model || 'unknown'}] (#{format_duration(elapsed)}):\n#{content}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def finalize_reply(bot, prompt, result, content, elapsed)
|
|
188
|
+
@tracker.record_turn(model: bot.model || 'unknown', input: prompt, result: result, elapsed: elapsed)
|
|
189
|
+
output_to_file(content)
|
|
190
|
+
display_metrics(result, elapsed: elapsed)
|
|
191
|
+
speak(content)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def display_metrics(result, elapsed: nil)
|
|
195
|
+
return unless AIA.config.flags.tokens
|
|
196
|
+
|
|
197
|
+
raw = result.respond_to?(:raw) ? result.raw : nil
|
|
198
|
+
return unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
|
|
199
|
+
|
|
200
|
+
model_id = extract_model_id(raw)
|
|
201
|
+
model_id ||= result.respond_to?(:robot_name) ? result.robot_name : "unknown"
|
|
202
|
+
metrics = {
|
|
203
|
+
model_id: model_id,
|
|
204
|
+
input_tokens: raw.input_tokens,
|
|
205
|
+
output_tokens: raw.output_tokens,
|
|
206
|
+
elapsed: elapsed
|
|
207
|
+
}
|
|
208
|
+
@ui_presenter.display_token_metrics(metrics)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def extract_model_id(message)
|
|
212
|
+
return message.model_id if message.respond_to?(:model_id) && message.model_id
|
|
213
|
+
return message.model if message.respond_to?(:model) && message.model
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/model_alias_registry.rb
|
|
4
|
+
#
|
|
5
|
+
# Configurable alias table that maps short names, provider names,
|
|
6
|
+
# and capability descriptors to model IDs.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
class ModelAliasRegistry
|
|
10
|
+
DEFAULT_ALIASES = {
|
|
11
|
+
# Short names
|
|
12
|
+
"claude" => "claude-sonnet-4-20250514",
|
|
13
|
+
"sonnet" => "claude-sonnet-4-20250514",
|
|
14
|
+
"opus" => "claude-opus-4-20250514",
|
|
15
|
+
"haiku" => "claude-haiku-4-5-20251001",
|
|
16
|
+
"gpt4" => "gpt-4o",
|
|
17
|
+
"gpt4o" => "gpt-4o",
|
|
18
|
+
"gpt4mini" => "gpt-4o-mini",
|
|
19
|
+
"gemini" => "gemini-2.0-flash",
|
|
20
|
+
"flash" => "gemini-2.0-flash",
|
|
21
|
+
"llama" => "llama-3.1-70b",
|
|
22
|
+
|
|
23
|
+
# Provider names (resolve to default model for provider)
|
|
24
|
+
"anthropic" => "claude-sonnet-4-20250514",
|
|
25
|
+
"openai" => "gpt-4o",
|
|
26
|
+
"google" => "gemini-2.0-flash",
|
|
27
|
+
"meta" => "llama-3.1-70b",
|
|
28
|
+
|
|
29
|
+
# Capability descriptors
|
|
30
|
+
"fast" => "claude-haiku-4-5-20251001",
|
|
31
|
+
"cheap" => "gpt-4o-mini",
|
|
32
|
+
"best" => "claude-opus-4-20250514",
|
|
33
|
+
"coding" => "claude-sonnet-4-20250514",
|
|
34
|
+
"vision" => "gpt-4o"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def initialize(custom_aliases = {})
|
|
38
|
+
@aliases = DEFAULT_ALIASES.merge(normalize_keys(custom_aliases))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve a single name or alias to a model ID.
|
|
42
|
+
#
|
|
43
|
+
# @param name_or_alias [String] model name, alias, or capability descriptor
|
|
44
|
+
# @return [String] resolved model ID
|
|
45
|
+
def resolve(name_or_alias)
|
|
46
|
+
normalized = normalize_key(name_or_alias)
|
|
47
|
+
@aliases[normalized] || fuzzy_match(name_or_alias) || name_or_alias.to_s.strip
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve a string containing multiple model references.
|
|
51
|
+
# Handles comma-separated, "and"-separated, and mixed formats.
|
|
52
|
+
#
|
|
53
|
+
# @param input [String] e.g., "claude and gemini", "gpt4, llama"
|
|
54
|
+
# @return [Array<String>] resolved model IDs
|
|
55
|
+
def resolve_multiple(input)
|
|
56
|
+
names = input.to_s.split(/\s*(?:,|and|&|\+)\s*/i)
|
|
57
|
+
names.map { |n| resolve(n.strip) }.uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check if a name is a known alias
|
|
61
|
+
#
|
|
62
|
+
# @param name [String] the name to check
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def known?(name)
|
|
65
|
+
@aliases.key?(normalize_key(name))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# List all known aliases
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash<String, String>]
|
|
71
|
+
def all_aliases
|
|
72
|
+
@aliases.dup
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def normalize_key(name)
|
|
78
|
+
name.to_s.strip.downcase.gsub(/[-_\s]+/, '')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_keys(hash)
|
|
82
|
+
hash.transform_keys { |k| normalize_key(k) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fuzzy_match(name)
|
|
86
|
+
return nil unless defined?(RubyLLM) && RubyLLM.respond_to?(:models)
|
|
87
|
+
|
|
88
|
+
target = name.to_s.downcase.strip
|
|
89
|
+
return nil if target.empty?
|
|
90
|
+
|
|
91
|
+
match = RubyLLM.models.find { |m| m.id.downcase.include?(target) }
|
|
92
|
+
match&.id
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/model_switch_handler.rb
|
|
4
|
+
#
|
|
5
|
+
# Handles natural language model-change intent detection.
|
|
6
|
+
# When a model-change intent is detected by the classification KB,
|
|
7
|
+
# extracts model names, confirms with the user, and reconfigures.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class ModelSwitchHandler
|
|
11
|
+
include HandlerProtocol
|
|
12
|
+
|
|
13
|
+
def initialize(alias_registry, ui_presenter)
|
|
14
|
+
@aliases = alias_registry
|
|
15
|
+
@ui = ui_presenter
|
|
16
|
+
@model_exists_cache = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Natural language model switching via KBS intent detection has been removed.
|
|
20
|
+
# Use the /model directive for model changes.
|
|
21
|
+
#
|
|
22
|
+
# @param context [HandlerContext]
|
|
23
|
+
# @return [Boolean] always false
|
|
24
|
+
def handle(context) # rubocop:disable Naming/PredicateMethod
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def handle_switch(intent, config)
|
|
31
|
+
models = extract_model_names(intent[:raw_text])
|
|
32
|
+
return false if models.empty?
|
|
33
|
+
|
|
34
|
+
resolved = models.map { |m| @aliases.resolve(m) }
|
|
35
|
+
confirm_and_apply(config, resolved)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_compare(intent, config) # rubocop:disable Naming/PredicateMethod
|
|
39
|
+
models = extract_model_names(intent[:raw_text])
|
|
40
|
+
return false if models.size < 2
|
|
41
|
+
|
|
42
|
+
resolved = models.map { |m| @aliases.resolve(m) }
|
|
43
|
+
|
|
44
|
+
@ui.display_info("Interpreted as: compare #{resolved.join(', ')}")
|
|
45
|
+
@ui.display_info("Proceed? (y/n)")
|
|
46
|
+
|
|
47
|
+
response = @ui.ask_question
|
|
48
|
+
return false unless response&.strip&.downcase == 'y'
|
|
49
|
+
|
|
50
|
+
config.flags.consensus = true
|
|
51
|
+
apply_model_change(config, resolved)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_capability_switch(intent, config)
|
|
56
|
+
capability = intent[:capability]
|
|
57
|
+
resolved = [@aliases.resolve(capability)]
|
|
58
|
+
confirm_and_apply(config, resolved)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def confirm_and_apply(config, resolved) # rubocop:disable Naming/PredicateMethod
|
|
62
|
+
@ui.display_info("Interpreted as: /model #{resolved.join(', ')}")
|
|
63
|
+
@ui.display_info("Proceed? (y/n)")
|
|
64
|
+
|
|
65
|
+
response = @ui.ask_question
|
|
66
|
+
return false unless response&.strip&.downcase == 'y'
|
|
67
|
+
|
|
68
|
+
apply_model_change(config, resolved)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_model_names(text)
|
|
73
|
+
return [] unless text
|
|
74
|
+
|
|
75
|
+
# Check each word against known aliases and model names
|
|
76
|
+
words = text.downcase.scan(/[\w.-]+/)
|
|
77
|
+
words.select { |w| @aliases.known?(w) || model_exists?(w) }.uniq
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def model_exists?(name)
|
|
81
|
+
return @model_exists_cache[name] if @model_exists_cache.key?(name)
|
|
82
|
+
@model_exists_cache[name] = begin
|
|
83
|
+
return false unless defined?(RubyLLM) && RubyLLM.respond_to?(:models)
|
|
84
|
+
!!RubyLLM.models.find(name)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def apply_model_change(config, model_names)
|
|
91
|
+
config.models = model_names.map { |name| AIA::ModelSpec.new(name: name) }
|
|
92
|
+
|
|
93
|
+
history_mode = config.respond_to?(:model_switch_history) ?
|
|
94
|
+
config.model_switch_history&.to_sym : :clean
|
|
95
|
+
|
|
96
|
+
AIA.client = RobotFactory.rebuild(config, history_mode: history_mode)
|
|
97
|
+
@ui.display_info("Model switched to: #{model_names.join(', ')}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/network_builder.rb
|
|
4
|
+
#
|
|
5
|
+
# Stateless module for building RobotLab::Network instances.
|
|
6
|
+
# Extracted from RobotFactory to reduce its size and isolate
|
|
7
|
+
# the network-specific construction logic.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
module NetworkBuilder
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Build a network where each prompt in the pipeline is a sequential step.
|
|
14
|
+
#
|
|
15
|
+
# @param config [AIA::Config]
|
|
16
|
+
# @param namer [RobotNamer]
|
|
17
|
+
# @return [RobotLab::Network]
|
|
18
|
+
# :reek:TooManyStatements -- gathers shared robot inputs then declares one network task per pipeline step
|
|
19
|
+
def build_pipeline_network(config, namer)
|
|
20
|
+
prompts = config.pipeline
|
|
21
|
+
run_config = RobotFactory.build_run_config(config)
|
|
22
|
+
tools = ToolLoader.filtered_tools(config)
|
|
23
|
+
mcp = RobotFactory.mcp_server_configs(config)
|
|
24
|
+
model_spec = config.models.first
|
|
25
|
+
|
|
26
|
+
RobotLab.create_network(name: "aia-pipeline") do
|
|
27
|
+
prev = nil
|
|
28
|
+
prompts.each_with_index do |_prompt_id, i|
|
|
29
|
+
task_name = :"step_#{i}"
|
|
30
|
+
robot = RobotFactory.build_robot(
|
|
31
|
+
model_spec,
|
|
32
|
+
name: "#{namer.name_for(model_spec.name)}-s#{i}",
|
|
33
|
+
system_prompt: nil,
|
|
34
|
+
local_tools: tools,
|
|
35
|
+
mcp_servers: mcp,
|
|
36
|
+
config: run_config
|
|
37
|
+
)
|
|
38
|
+
task task_name, robot, depends_on: prev ? [prev] : :none
|
|
39
|
+
prev = task_name
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Build a network where multiple models run in parallel.
|
|
45
|
+
#
|
|
46
|
+
# @param config [AIA::Config]
|
|
47
|
+
# @param namer [RobotNamer]
|
|
48
|
+
# @return [RobotLab::Network]
|
|
49
|
+
def build_parallel_network(config, namer)
|
|
50
|
+
run_config = RobotFactory.build_run_config(config)
|
|
51
|
+
tools = ToolLoader.filtered_tools(config)
|
|
52
|
+
mcp = RobotFactory.mcp_server_configs(config)
|
|
53
|
+
aia_config = config
|
|
54
|
+
roster = config.models.map { |spec| { name: namer.name_for(spec.name), spec: spec } }
|
|
55
|
+
build_robot = ->(entry) { build_roster_robot(entry, roster, tools, mcp, run_config, aia_config) }
|
|
56
|
+
|
|
57
|
+
RobotLab.create_network(name: "aia-parallel") do
|
|
58
|
+
roster.each do |entry|
|
|
59
|
+
task entry[:spec].internal_id.to_sym, build_robot.call(entry), depends_on: :none
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Build a consensus network: all models run in parallel, then a
|
|
65
|
+
# synthesizer merges the results.
|
|
66
|
+
#
|
|
67
|
+
# @param config [AIA::Config]
|
|
68
|
+
# @param namer [RobotNamer]
|
|
69
|
+
# @return [RobotLab::Network]
|
|
70
|
+
# :reek:TooManyStatements -- gathers shared robot inputs, declares one task per roster member plus the synthesizer
|
|
71
|
+
def build_consensus_network(config, namer)
|
|
72
|
+
run_config = RobotFactory.build_run_config(config)
|
|
73
|
+
tools = ToolLoader.filtered_tools(config)
|
|
74
|
+
mcp = RobotFactory.mcp_server_configs(config)
|
|
75
|
+
models = config.models
|
|
76
|
+
primary = models.first
|
|
77
|
+
aia_config = config
|
|
78
|
+
roster = models.map { |spec| { name: namer.name_for(spec.name), spec: spec } }
|
|
79
|
+
build_robot = ->(entry) { build_roster_robot(entry, roster, tools, mcp, run_config, aia_config) }
|
|
80
|
+
|
|
81
|
+
RobotLab.create_network(name: "aia-consensus") do
|
|
82
|
+
roster.each do |entry|
|
|
83
|
+
task entry[:spec].internal_id.to_sym, build_robot.call(entry), depends_on: :none
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
synthesizer = RobotFactory.build_robot(
|
|
87
|
+
primary,
|
|
88
|
+
name: "Weaver",
|
|
89
|
+
system_prompt: "You are a synthesizer. Review the responses from multiple AI models " \
|
|
90
|
+
"and create a unified, coherent response that captures the best " \
|
|
91
|
+
"insights from each.",
|
|
92
|
+
config: run_config
|
|
93
|
+
)
|
|
94
|
+
task :consensus, synthesizer,
|
|
95
|
+
depends_on: models.map { |s| s.internal_id.to_sym }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Shared helper: builds a single roster robot (used by parallel and consensus networks).
|
|
100
|
+
# :reek:LongParameterList { max_params: 6 } -- pass-through of the shared per-network build inputs
|
|
101
|
+
def build_roster_robot(entry, roster, tools, mcp, run_config, aia_config)
|
|
102
|
+
spec = entry[:spec]
|
|
103
|
+
identity = SystemPromptAssembler.build_identity_prompt(entry[:name], spec, roster)
|
|
104
|
+
base_prompt = SystemPromptAssembler.resolve_system_prompt(aia_config, spec)
|
|
105
|
+
system_prompt = [identity, base_prompt].compact.join("\n\n")
|
|
106
|
+
RobotFactory.build_robot(
|
|
107
|
+
spec,
|
|
108
|
+
name: entry[:name],
|
|
109
|
+
system_prompt: system_prompt,
|
|
110
|
+
local_tools: tools,
|
|
111
|
+
mcp_servers: mcp,
|
|
112
|
+
config: run_config
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Build a concurrent MCP network where independent server groups
|
|
117
|
+
# run in parallel with a synthesizer to merge results.
|
|
118
|
+
#
|
|
119
|
+
# @param config [AIA::Config]
|
|
120
|
+
# @param namer [RobotNamer]
|
|
121
|
+
# @param server_groups [Array<Array<Hash>>]
|
|
122
|
+
# @return [RobotLab::Network]
|
|
123
|
+
# :reek:TooManyStatements -- gathers shared robot inputs, declares one task per server group plus the synthesizer
|
|
124
|
+
def build_concurrent_mcp_network(config, namer, server_groups)
|
|
125
|
+
run_config = RobotFactory.build_run_config(config)
|
|
126
|
+
tools = ToolLoader.filtered_tools(config)
|
|
127
|
+
model_spec = config.models.first
|
|
128
|
+
system_prompt = SystemPromptAssembler.resolve_system_prompt(config, model_spec)
|
|
129
|
+
|
|
130
|
+
network = RobotLab.create_network(name: "aia-concurrent-mcp") do
|
|
131
|
+
server_groups.each_with_index do |group, i|
|
|
132
|
+
robot = RobotFactory.build_robot(
|
|
133
|
+
model_spec,
|
|
134
|
+
name: "#{namer.name_for(model_spec.name)}-w#{i}",
|
|
135
|
+
system_prompt: system_prompt,
|
|
136
|
+
local_tools: tools,
|
|
137
|
+
mcp_servers: group.map { |s| RobotFactory.normalize_mcp_config(s) },
|
|
138
|
+
config: run_config
|
|
139
|
+
)
|
|
140
|
+
task :"mcp_worker_#{i}", robot, depends_on: :none
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
synthesizer = RobotFactory.build_robot(
|
|
144
|
+
model_spec,
|
|
145
|
+
name: "Weaver",
|
|
146
|
+
system_prompt: "You are a synthesizer. Merge the following results from " \
|
|
147
|
+
"multiple specialized queries into a coherent response. " \
|
|
148
|
+
"Preserve all relevant information. Resolve contradictions.",
|
|
149
|
+
config: run_config
|
|
150
|
+
)
|
|
151
|
+
task :synthesize, synthesizer,
|
|
152
|
+
depends_on: server_groups.each_index.map { |i| :"mcp_worker_#{i}" }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
RobotFactory.initialize_network_memory(network, config)
|
|
156
|
+
RobotFactory.setup_memory_subscriptions(network, config)
|
|
157
|
+
network
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/network_memory_manager.rb
|
|
4
|
+
#
|
|
5
|
+
# Manages shared memory initialization and subscriptions for
|
|
6
|
+
# multi-robot networks. Extracted from RobotFactory.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
class NetworkMemoryManager
|
|
10
|
+
class << self
|
|
11
|
+
# Initialize shared memory for a network with session context.
|
|
12
|
+
#
|
|
13
|
+
# @param network [RobotLab::Network]
|
|
14
|
+
# @param config [AIA::Config]
|
|
15
|
+
# @return [RobotLab::Network]
|
|
16
|
+
def initialize_memory(network, config)
|
|
17
|
+
return network unless network.respond_to?(:memory)
|
|
18
|
+
|
|
19
|
+
data = network.memory.data
|
|
20
|
+
data.session_id = SecureRandom.hex(8)
|
|
21
|
+
data.model_count = config.models.size
|
|
22
|
+
data.model_names = config.models.map(&:name)
|
|
23
|
+
data.mode = config.flags.consensus ? :consensus : :parallel
|
|
24
|
+
data.turn_count = 0
|
|
25
|
+
|
|
26
|
+
network
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Set up memory subscriptions for debug logging and completion tracking.
|
|
30
|
+
#
|
|
31
|
+
# @param network [RobotLab::Network]
|
|
32
|
+
# @param config [AIA::Config]
|
|
33
|
+
def setup_subscriptions(network, config)
|
|
34
|
+
return unless network.respond_to?(:memory)
|
|
35
|
+
|
|
36
|
+
memory = network.memory
|
|
37
|
+
|
|
38
|
+
if config.flags.debug
|
|
39
|
+
memory.subscribe_pattern("result_*") do |change|
|
|
40
|
+
AIA::LoggerManager.aia_logger.debug(
|
|
41
|
+
"Memory: #{change.key} by #{change.writer} at #{change.timestamp}"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
memory.set(:completed_count, 0)
|
|
47
|
+
memory.subscribe_pattern("result_*") do |change|
|
|
48
|
+
next unless change.created?
|
|
49
|
+
count = memory.get(:completed_count) || 0
|
|
50
|
+
memory.set(:completed_count, count + 1)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# lib/aia/patches/ruby_llm_streaming_error.rb
|
|
2
|
+
#
|
|
3
|
+
# Patches RubyLLM::Streaming#handle_error_chunk to locate the SSE `data:` line
|
|
4
|
+
# robustly.
|
|
5
|
+
#
|
|
6
|
+
# The shipped implementation assumes the `data:` line is the SECOND line of the
|
|
7
|
+
# same chunk as `event: error`:
|
|
8
|
+
#
|
|
9
|
+
# error_data = chunk.split("\n")[1].delete_prefix('data: ')
|
|
10
|
+
#
|
|
11
|
+
# When a provider flushes `event: error` and its `data:` line in SEPARATE stream
|
|
12
|
+
# chunks (LM Studio does this on, e.g., a context-length overflow),
|
|
13
|
+
# chunk.split("\n")[1] is nil and the real provider message is masked as the
|
|
14
|
+
# cryptic `NoMethodError: undefined method 'delete_prefix' for nil`. This
|
|
15
|
+
# version scans the chunk for the `data:` line so the actual error propagates to
|
|
16
|
+
# the user. Falls back to the whole chunk when no data line is present, which
|
|
17
|
+
# still parses cleanly instead of raising.
|
|
18
|
+
#
|
|
19
|
+
# Applied via #prepend so it cleanly overrides the buggy method while reusing
|
|
20
|
+
# ruby_llm's own private parse_error_from_json. Guarded against API drift: if
|
|
21
|
+
# parse_error_from_json is renamed/removed the patch is skipped with a warning.
|
|
22
|
+
#
|
|
23
|
+
# NOTE: This fixes a genuine upstream bug — it should be contributed to
|
|
24
|
+
# crmne/ruby_llm so this patch can eventually be retired.
|
|
25
|
+
|
|
26
|
+
module AIA
|
|
27
|
+
module RubyLLMStreamingError
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def handle_error_chunk(chunk, env)
|
|
31
|
+
data_line = chunk.to_s.lines.find { |line| line.start_with?('data:') }
|
|
32
|
+
error_data = data_line ? data_line.sub(/\Adata:\s*/, '').strip : chunk.to_s.strip
|
|
33
|
+
parse_error_from_json(error_data, env, 'Failed to parse error chunk')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if RubyLLM::Streaming.private_method_defined?(:parse_error_from_json)
|
|
39
|
+
RubyLLM::Streaming.prepend(AIA::RubyLLMStreamingError)
|
|
40
|
+
else
|
|
41
|
+
$stderr.puts "WARNING: [AIA] ruby_llm patch skipped: RubyLLM::Streaming#parse_error_from_json " \
|
|
42
|
+
"not found (ruby_llm #{RubyLLM::VERSION}). Streaming error messages may be cryptic."
|
|
43
|
+
end
|