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.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +9 -1
  3. data/.loki +11 -0
  4. data/.reek.yml +160 -0
  5. data/.rubocop.yml +116 -0
  6. data/.rubocop_strict.yml +15 -0
  7. data/.version +1 -1
  8. data/CHANGELOG.md +269 -55
  9. data/IMPLEMENTATION_PLAN.md +506 -0
  10. data/README.md +267 -239
  11. data/Rakefile +5 -5
  12. data/_typos.toml +10 -0
  13. data/aia.gemspec +92 -0
  14. data/architecture_review.md +314 -0
  15. data/bin/aia +16 -0
  16. data/config/aia.yml +13 -0
  17. data/docs/AGENTS.md +40 -0
  18. data/docs/advanced-prompting.md +67 -3
  19. data/docs/cli-reference.md +312 -56
  20. data/docs/configuration.md +130 -19
  21. data/docs/contributing.md +56 -2
  22. data/docs/directives-reference.md +593 -78
  23. data/docs/faq.md +85 -3
  24. data/docs/guides/available-models.md +1 -1
  25. data/docs/guides/basic-usage.md +6 -6
  26. data/docs/guides/chat.md +40 -16
  27. data/docs/guides/crew.md +239 -0
  28. data/docs/guides/executable-prompts.md +1 -1
  29. data/docs/guides/index.md +1 -0
  30. data/docs/guides/models.md +15 -0
  31. data/docs/index.md +29 -2
  32. data/docs/installation.md +44 -17
  33. data/docs/mcp-integration.md +40 -0
  34. data/docs/prompt_management.md +85 -86
  35. data/docs/security.md +47 -0
  36. data/docs/special_projects_guide.md +386 -0
  37. data/docs/tools-and-mcp-examples.md +23 -0
  38. data/docs/workflows-and-pipelines.md +84 -7
  39. data/examples/.gitignore +1 -0
  40. data/examples/00_setup_aia.sh +27 -44
  41. data/examples/11_multi_model.sh +4 -14
  42. data/examples/12_token_usage.sh +3 -12
  43. data/examples/18_tools.sh +10 -2
  44. data/examples/22_chat_mode.sh +0 -10
  45. data/examples/23_verify.sh +139 -0
  46. data/examples/24_decompose.sh +139 -0
  47. data/examples/25_spawn.sh +139 -0
  48. data/examples/26_debate.sh +97 -0
  49. data/examples/27_mention_routing.sh +157 -0
  50. data/examples/28_model_switching.sh +106 -0
  51. data/examples/29_agent_harness.sh +177 -0
  52. data/examples/README.md +65 -0
  53. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  54. data/examples/aia_config.yml +1 -1
  55. data/examples/aia_config_orchestrator.yml +45 -0
  56. data/examples/common.sh +18 -6
  57. data/examples/context/tech_stack.md +2 -2
  58. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  59. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  60. data/examples/rules/01_classify_ruby.rb +16 -0
  61. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  62. data/examples/rules/03_gate_prompt_length.rb +19 -0
  63. data/examples/rules/04_tool_selection.rb +41 -0
  64. data/examples/rules/README.md +30 -0
  65. data/examples/run_all.sh +48 -15
  66. data/examples/tools/word_count_tool.rb +1 -1
  67. data/lib/AGENTS.md +57 -0
  68. data/lib/aia/chat_loop.rb +263 -167
  69. data/lib/aia/config/cli_parser.rb +217 -145
  70. data/lib/aia/config/defaults.yml +62 -33
  71. data/lib/aia/config/mcp_parser.rb +52 -51
  72. data/lib/aia/config/model_spec.rb +34 -2
  73. data/lib/aia/config/validator.rb +171 -216
  74. data/lib/aia/config.rb +111 -145
  75. data/lib/aia/content_extractor.rb +155 -0
  76. data/lib/aia/cost_calculator.rb +39 -0
  77. data/lib/aia/crew.rb +164 -0
  78. data/lib/aia/debate_handler.rb +174 -0
  79. data/lib/aia/delegate_handler.rb +116 -0
  80. data/lib/aia/directive.rb +43 -26
  81. data/lib/aia/directive_processor.rb +16 -7
  82. data/lib/aia/directives/configuration_directives.rb +214 -60
  83. data/lib/aia/directives/context_directives.rb +67 -52
  84. data/lib/aia/directives/execution_directives.rb +141 -4
  85. data/lib/aia/directives/model_directives.rb +163 -141
  86. data/lib/aia/directives/trakflow_directives.rb +62 -0
  87. data/lib/aia/directives/utility_directives.rb +227 -30
  88. data/lib/aia/directives/web_and_file_directives.rb +126 -77
  89. data/lib/aia/errors.rb +15 -0
  90. data/lib/aia/fact_asserter.rb +27 -0
  91. data/lib/aia/fzf.rb +9 -31
  92. data/lib/aia/handler_context.rb +17 -0
  93. data/lib/aia/handler_protocol.rb +19 -0
  94. data/lib/aia/history_transfer.rb +55 -0
  95. data/lib/aia/input_collector.rb +3 -3
  96. data/lib/aia/layered_orchestrator.rb +471 -0
  97. data/lib/aia/logger.rb +45 -25
  98. data/lib/aia/mcp_config_normalizer.rb +35 -0
  99. data/lib/aia/mcp_connection_manager.rb +315 -0
  100. data/lib/aia/mcp_discovery.rb +44 -0
  101. data/lib/aia/mcp_grouper.rb +33 -0
  102. data/lib/aia/mcp_server_config.rb +30 -0
  103. data/lib/aia/mcp_utility.rb +60 -0
  104. data/lib/aia/mention_router.rb +217 -0
  105. data/lib/aia/model_alias_registry.rb +97 -0
  106. data/lib/aia/model_switch_handler.rb +100 -0
  107. data/lib/aia/network_builder.rb +160 -0
  108. data/lib/aia/network_memory_manager.rb +55 -0
  109. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  110. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  111. data/lib/aia/pipeline_orchestrator.rb +272 -0
  112. data/lib/aia/plugin_loader.rb +170 -0
  113. data/lib/aia/plugin_monitor.rb +211 -0
  114. data/lib/aia/prompt_decomposer.rb +159 -0
  115. data/lib/aia/prompt_handler.rb +56 -82
  116. data/lib/aia/robot_builder.rb +51 -0
  117. data/lib/aia/robot_factory.rb +338 -0
  118. data/lib/aia/robot_namer.rb +110 -0
  119. data/lib/aia/session.rb +87 -17
  120. data/lib/aia/session_tracker.rb +207 -0
  121. data/lib/aia/similarity_scorer.rb +41 -0
  122. data/lib/aia/skill_utils.rb +105 -1
  123. data/lib/aia/spawn_handler.rb +129 -0
  124. data/lib/aia/spawn_spec_parser.rb +65 -0
  125. data/lib/aia/special_mode_handler.rb +322 -0
  126. data/lib/aia/speech.rb +67 -0
  127. data/lib/aia/startup_coordinator.rb +151 -0
  128. data/lib/aia/streaming_runner.rb +172 -0
  129. data/lib/aia/system_prompt_assembler.rb +92 -0
  130. data/lib/aia/task_coordinator.rb +207 -0
  131. data/lib/aia/task_decomposer.rb +57 -0
  132. data/lib/aia/task_executor.rb +51 -0
  133. data/lib/aia/tfidf_math.rb +27 -0
  134. data/lib/aia/timing.rb +15 -0
  135. data/lib/aia/tool_filter/tfidf.rb +116 -0
  136. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  137. data/lib/aia/tool_filter.rb +83 -0
  138. data/lib/aia/tool_filter_registry.rb +30 -0
  139. data/lib/aia/tool_filter_strategy.rb +146 -0
  140. data/lib/aia/tool_introspection.rb +17 -0
  141. data/lib/aia/tool_loader.rb +216 -0
  142. data/lib/aia/tool_utility.rb +30 -0
  143. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  144. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  145. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  146. data/lib/aia/tools/task_board_tool.rb +115 -0
  147. data/lib/aia/trakflow_bridge.rb +175 -0
  148. data/lib/aia/turn_state.rb +95 -0
  149. data/lib/aia/ui_presenter.rb +182 -206
  150. data/lib/aia/utility.rb +136 -87
  151. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
  152. data/lib/aia/verification_network.rb +57 -0
  153. data/lib/aia.rb +124 -63
  154. data/mkdocs.yml +1 -0
  155. metadata +187 -58
  156. data/justfile +0 -215
  157. data/lib/aia/adapter/chat_execution.rb +0 -242
  158. data/lib/aia/adapter/error_handler.rb +0 -68
  159. data/lib/aia/adapter/gem_activator.rb +0 -57
  160. data/lib/aia/adapter/mcp_connector.rb +0 -274
  161. data/lib/aia/adapter/modality_handlers.rb +0 -167
  162. data/lib/aia/adapter/model_registry.rb +0 -81
  163. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  164. data/lib/aia/adapter/provider_configurator.rb +0 -59
  165. data/lib/aia/adapter/tool_filter.rb +0 -85
  166. data/lib/aia/adapter/tool_loader.rb +0 -90
  167. data/lib/aia/chat_processor_service.rb +0 -178
  168. data/lib/aia/prompt_pipeline.rb +0 -183
  169. data/lib/aia/ruby_llm_adapter.rb +0 -95
  170. data/lib/extensions/openstruct_merge.rb +0 -48
  171. data/lib/extensions/ruby_llm/.irbrc +0 -56
  172. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  173. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  174. data/lib/refinements/string.rb +0 -16
  175. 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