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
@@ -3,15 +3,15 @@
3
3
  require 'pm'
4
4
  require 'erb'
5
5
 
6
-
7
6
  module AIA
7
+ # :reek:TooManyMethods -- prompt/role fetching, front-matter merging, and fzf search form one lookup pipeline of small named steps
8
8
  class PromptHandler
9
9
  include AIA::SkillUtils
10
10
 
11
11
  # Struct for path-based role content (bypasses PM parsing)
12
12
  RoleContent = Struct.new(:content) do
13
- def to_s; content; end
14
- def metadata; nil; end
13
+ def to_s = content
14
+ def metadata = nil
15
15
  end
16
16
 
17
17
  # Root-level YAML keys that are shorthands for deeper config paths
@@ -25,7 +25,7 @@ module AIA
25
25
  'next' => [%w[config next], %w[config pipeline]],
26
26
  'pipeline' => [%w[config pipeline], %w[config next]],
27
27
  'shell' => [%w[config shell], %w[config flags shell]],
28
- 'erb' => [%w[config erb], %w[config flags erb]],
28
+ 'erb' => [%w[config erb], %w[config flags erb]]
29
29
  }.freeze
30
30
 
31
31
  def initialize
@@ -39,7 +39,6 @@ module AIA
39
39
  register_pm_directives
40
40
  end
41
41
 
42
-
43
42
  def fetch_prompt(prompt_id)
44
43
  if prompt_id == '__FUZZY_SEARCH__'
45
44
  return fuzzy_search_prompt('')
@@ -54,7 +53,7 @@ module AIA
54
53
  parsed = if File.exist?(prompt_file_path)
55
54
  PM.parse(prompt_id)
56
55
  else
57
- warn "Warning: Invalid prompt ID or file not found: #{prompt_id}"
56
+ $stderr.puts "Warning: Invalid prompt ID or file not found: #{prompt_id}"
58
57
  logger.warn("Invalid prompt ID or file not found: #{prompt_id}")
59
58
  handle_missing_prompt(prompt_id)
60
59
  end
@@ -63,23 +62,23 @@ module AIA
63
62
  parsed
64
63
  end
65
64
 
66
-
67
65
  def fetch_role(role_id)
68
66
  return handle_missing_role("roles/") if role_id.nil?
69
67
  return fetch_role_from_path(role_id) if path_based_id?(role_id)
70
68
 
71
- unless role_id.start_with?(AIA.config.prompts.roles_prefix)
72
- role_id = "#{AIA.config.prompts.roles_prefix}/#{role_id}"
69
+ prompts_cfg = AIA.config.prompts
70
+ unless role_id.start_with?(prompts_cfg.roles_prefix)
71
+ role_id = "#{prompts_cfg.roles_prefix}/#{role_id}"
73
72
  end
74
73
 
75
- role_file_path = File.join(@prompts_dir, "#{role_id}#{AIA.config.prompts.extname}")
74
+ role_file_path = File.join(@prompts_dir, "#{role_id}#{prompts_cfg.extname}")
76
75
 
77
76
  parsed = if File.exist?(role_file_path)
78
77
  # PM.parse(role_id) cannot resolve subdirectory IDs like "roles/jersey_mike"
79
78
  # and returns the ID string as content. Parse from raw file content instead.
80
79
  PM.parse_string(File.read(role_file_path))
81
80
  else
82
- warn "Warning: Invalid role ID or file not found: #{role_id}"
81
+ $stderr.puts "Warning: Invalid role ID or file not found: #{role_id}"
83
82
  logger.warn("Invalid role ID or file not found: #{role_id}")
84
83
  handle_missing_role(role_id)
85
84
  end
@@ -88,7 +87,6 @@ module AIA
88
87
  parsed
89
88
  end
90
89
 
91
-
92
90
  # Load role for a specific model (ADR-005)
93
91
  # Takes a model spec hash and default role, returns rendered role text
94
92
  def load_role_for_model(model_spec, default_role = nil)
@@ -103,12 +101,11 @@ module AIA
103
101
  role_parsed = fetch_role(role_id)
104
102
  role_parsed.to_s
105
103
  rescue => e
106
- warn "Warning: Could not load role '#{role_id}' for model: #{e.message}"
104
+ $stderr.puts "Warning: Could not load role '#{role_id}' for model: #{e.message}"
107
105
  logger.warn("Could not load role '#{role_id}' for model: #{e.message}")
108
106
  nil
109
107
  end
110
108
 
111
-
112
109
  # Applies YAML front matter metadata to AIA.config.
113
110
  # Processes root-level shorthand keys, detects conflicts with config: section,
114
111
  # and deep merges the config: section into AIA.config.
@@ -132,10 +129,8 @@ module AIA
132
129
  deep_merge_config(config_section) if config_section
133
130
  end
134
131
 
135
-
136
132
  private
137
133
 
138
-
139
134
  def fetch_executable_prompt
140
135
  content = AIA.config.executable_prompt_content
141
136
  parsed = PM.parse(content)
@@ -143,18 +138,16 @@ module AIA
143
138
  parsed
144
139
  end
145
140
 
146
-
147
141
  def register_pm_directives
148
142
  AIA::Directive.register_all
149
143
  end
150
144
 
151
-
152
145
  # Detect conflicts between root shorthand keys and the config: section.
153
146
  # Raises AIA::ConfigurationError when the same setting is specified in both places.
154
147
  def detect_shorthand_conflicts(meta_hash, config_section)
155
148
  # Check for mutually exclusive root keys: next and pipeline
156
- has_next = meta_hash.key?('next') || meta_hash.key?(:next)
157
- has_pipeline = meta_hash.key?('pipeline') || meta_hash.key?(:pipeline)
149
+ has_next = meta_hash.key?('next') || meta_hash.key?(:next)
150
+ has_pipeline = meta_hash.key?('pipeline') || meta_hash.key?(:pipeline)
158
151
 
159
152
  if has_next && has_pipeline
160
153
  raise ConfigurationError, "Both 'next' and 'pipeline' specified at root level — they are mutually exclusive"
@@ -173,64 +166,54 @@ module AIA
173
166
 
174
167
  unless value.nil?
175
168
  raise ConfigurationError,
176
- "Conflict: '#{root_key}' at root level and '#{path.join('.')}' in config section"
169
+ "Conflict: '#{root_key}' at root level and '#{path.join('.')}' in config section"
177
170
  end
178
171
  end
179
172
  end
180
173
  end
181
174
 
182
-
183
175
  # Apply root-level shorthand keys to AIA.config
176
+ # :reek:TooManyStatements -- one guarded assignment per supported front-matter shorthand key
184
177
  def apply_root_shorthands(meta_hash)
185
- # model → AIA.config.models (replace with single-model array)
186
- model_val = meta_hash['model'] || meta_hash[:model]
187
- if model_val
188
- AIA.config.models = [model_val]
189
- end
178
+ cfg = AIA.config
190
179
 
191
- # temperature → AIA.config.llm.temperature
192
- temp_val = meta_hash['temperature'] || meta_hash[:temperature]
193
- if temp_val
194
- AIA.config.llm.temperature = temp_val
195
- end
180
+ # model → cfg.models (replace with single-model array)
181
+ model_val = shorthand(meta_hash, :model)
182
+ cfg.models = [model_val] if model_val
196
183
 
197
- # top_p → AIA.config.llm.top_p
198
- top_p_val = meta_hash['top_p'] || meta_hash[:top_p]
199
- if top_p_val
200
- AIA.config.llm.top_p = top_p_val
201
- end
184
+ temp_val = shorthand(meta_hash, :temperature)
185
+ cfg.llm.temperature = temp_val if temp_val
202
186
 
203
- # next → AIA.config.pipeline (replace)
204
- next_val = meta_hash['next'] || meta_hash[:next]
205
- if next_val
206
- if AIA.config.pipeline.any?
207
- logger.info "Prompt metadata 'next: #{next_val}' overrides remaining pipeline #{AIA.config.pipeline.inspect}"
208
- end
209
- AIA.config.pipeline = [next_val]
210
- end
187
+ top_p_val = shorthand(meta_hash, :top_p)
188
+ cfg.llm.top_p = top_p_val if top_p_val
211
189
 
212
- # pipeline → AIA.config.pipeline (replace)
213
- pipeline_val = meta_hash['pipeline'] || meta_hash[:pipeline]
214
- if pipeline_val
215
- if AIA.config.pipeline.any?
216
- logger.info "Prompt metadata 'pipeline' overrides remaining pipeline #{AIA.config.pipeline.inspect}"
217
- end
218
- AIA.config.pipeline = Array(pipeline_val)
219
- end
190
+ # next / pipeline → cfg.pipeline (replace)
191
+ next_val = shorthand(meta_hash, :next)
192
+ apply_pipeline_shorthand(cfg, [next_val], "next: #{next_val}") if next_val
220
193
 
221
- # shell → AIA.config.flags.shell (and PM's shell via metadata)
222
- shell_val = meta_hash['shell'] || meta_hash[:shell]
223
- unless shell_val.nil?
224
- AIA.config.flags.shell = shell_val
225
- end
194
+ pipeline_val = shorthand(meta_hash, :pipeline)
195
+ apply_pipeline_shorthand(cfg, Array(pipeline_val), "pipeline") if pipeline_val
226
196
 
227
- # erb → AIA.config.flags.erb (and PM's erb via metadata)
228
- erb_val = meta_hash['erb'] || meta_hash[:erb]
229
- unless erb_val.nil?
230
- AIA.config.flags.erb = erb_val
231
- end
197
+ # shell / erb → cfg.flags (and PM's shell/erb via metadata)
198
+ shell_val = shorthand(meta_hash, :shell)
199
+ cfg.flags.shell = shell_val unless shell_val.nil?
200
+
201
+ erb_val = shorthand(meta_hash, :erb)
202
+ cfg.flags.erb = erb_val unless erb_val.nil?
232
203
  end
233
204
 
205
+ # Fetch a front-matter shorthand value by string or symbol key.
206
+ def shorthand(meta_hash, key)
207
+ meta_hash[key.to_s] || meta_hash[key]
208
+ end
209
+
210
+ # Replace cfg.pipeline, logging when a remaining pipeline is overridden.
211
+ def apply_pipeline_shorthand(cfg, new_pipeline, label)
212
+ if cfg.pipeline.any?
213
+ logger.info "Prompt metadata '#{label}' overrides remaining pipeline #{cfg.pipeline.inspect}"
214
+ end
215
+ cfg.pipeline = new_pipeline
216
+ end
234
217
 
235
218
  def logger
236
219
  @logger ||= LoggerManager.aia_logger
@@ -253,7 +236,6 @@ module AIA
253
236
  end
254
237
  end
255
238
 
256
-
257
239
  # Recursively merge a hash into a config object
258
240
  def deep_merge_into_config(config_obj, hash)
259
241
  hash.each do |key, value|
@@ -266,7 +248,6 @@ module AIA
266
248
  end
267
249
  end
268
250
 
269
-
270
251
  # Dig into a hash with an array of keys, returning nil if any key is missing
271
252
  def dig_hash(hash, keys)
272
253
  keys.reduce(hash) do |h, key|
@@ -275,7 +256,6 @@ module AIA
275
256
  end
276
257
  end
277
258
 
278
-
279
259
  # Recursively symbolize all keys in a hash
280
260
  def symbolize_keys_deep(obj)
281
261
  case obj
@@ -290,23 +270,21 @@ module AIA
290
270
  end
291
271
  end
292
272
 
293
-
294
273
  def handle_missing_prompt(prompt_id)
295
274
  prompt_id = prompt_id.to_s.strip
296
275
  if prompt_id.empty?
297
- warn "Error: Prompt ID cannot be empty"
276
+ $stderr.puts "Error: Prompt ID cannot be empty"
298
277
  exit 1
299
278
  end
300
279
 
301
280
  if AIA.config.flags.fuzzy
302
281
  fuzzy_search_prompt(prompt_id)
303
282
  else
304
- warn "Error: Could not find prompt with ID: #{prompt_id}"
283
+ $stderr.puts "Error: Could not find prompt with ID: #{prompt_id}"
305
284
  exit 1
306
285
  end
307
286
  end
308
287
 
309
-
310
288
  def fetch_role_from_path(role_id)
311
289
  expanded = File.expand_path(role_id)
312
290
  expanded += '.md' if File.extname(expanded).empty?
@@ -320,7 +298,6 @@ module AIA
320
298
  RoleContent.new(File.read(expanded))
321
299
  end
322
300
 
323
-
324
301
  def fuzzy_search_prompt(prompt_id)
325
302
  new_prompt_id = search_prompt_id_with_fzf(prompt_id)
326
303
 
@@ -331,23 +308,21 @@ module AIA
331
308
  PM.parse(new_prompt_id)
332
309
  end
333
310
 
334
-
335
311
  def handle_missing_role(role_id)
336
312
  role_id = role_id.to_s.strip
337
313
  if role_id.empty? || role_id == "roles/"
338
- warn "Error: Role ID cannot be empty"
314
+ $stderr.puts "Error: Role ID cannot be empty"
339
315
  exit 1
340
316
  end
341
317
 
342
318
  if AIA.config.flags.fuzzy
343
319
  fuzzy_search_role(role_id)
344
320
  else
345
- warn "Error: Could not find role with ID: #{role_id}"
321
+ $stderr.puts "Error: Could not find role with ID: #{role_id}"
346
322
  exit 1
347
323
  end
348
324
  end
349
325
 
350
-
351
326
  def fuzzy_search_role(role_id)
352
327
  new_role_id = search_role_id_with_fzf(role_id)
353
328
  if new_role_id.nil? || new_role_id.empty?
@@ -357,10 +332,9 @@ module AIA
357
332
  PM.parse(new_role_id)
358
333
  end
359
334
 
360
-
361
335
  def search_prompt_id_with_fzf(initial_query)
362
336
  prompt_files = Dir.glob(File.join(@prompts_dir, "*#{AIA.config.prompts.extname}"))
363
- .map { |file| File.basename(file, AIA.config.prompts.extname) }
337
+ .map { |file| File.basename(file, AIA.config.prompts.extname) }
364
338
  fzf = AIA::Fzf.new(
365
339
  list: prompt_files,
366
340
  directory: @prompts_dir,
@@ -371,10 +345,10 @@ module AIA
371
345
  fzf.run || (raise "No prompt ID selected")
372
346
  end
373
347
 
374
-
375
348
  def search_role_id_with_fzf(initial_query)
376
- role_files = Dir.glob(File.join(@roles_dir, "*#{AIA.config.prompts.extname}"))
377
- .map { |file| File.basename(file, AIA.config.prompts.extname) }
349
+ prompts_cfg = AIA.config.prompts
350
+ role_files = Dir.glob(File.join(@roles_dir, "*#{prompts_cfg.extname}"))
351
+ .map { |file| File.basename(file, prompts_cfg.extname) }
378
352
  fzf = AIA::Fzf.new(
379
353
  list: role_files,
380
354
  directory: @prompts_dir,
@@ -389,8 +363,8 @@ module AIA
389
363
  raise "No role ID selected"
390
364
  end
391
365
 
392
- unless role.start_with?(AIA.config.prompts.roles_prefix)
393
- role = AIA.config.prompts.roles_prefix + '/' + role
366
+ unless role.start_with?(prompts_cfg.roles_prefix)
367
+ role = prompts_cfg.roles_prefix + '/' + role
394
368
  end
395
369
 
396
370
  role
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/robot_builder.rb
4
+ #
5
+ # Builds a single RobotLab::Robot from AIA configuration.
6
+ # Extracted from RobotFactory to isolate single-robot construction.
7
+
8
+ module AIA
9
+ class RobotBuilder
10
+ class << self
11
+ # Build a single robot for one model spec.
12
+ #
13
+ # @param config [AIA::Config]
14
+ # @param namer [RobotNamer]
15
+ # @return [RobotLab::Robot]
16
+ def build(config, namer:)
17
+ model_spec = config.models.first
18
+ robot_name = namer.name_for(model_spec.name)
19
+ roster = [{ name: robot_name, spec: model_spec }]
20
+ identity = SystemPromptAssembler.build_identity_prompt(robot_name, model_spec, roster)
21
+ base_prompt = SystemPromptAssembler.resolve_system_prompt(config, model_spec)
22
+ thinking_note = thinking_mode_instruction(model_spec)
23
+ system_prompt = [identity, base_prompt, thinking_note].compact.join("\n\n")
24
+
25
+ RobotFactory.build_robot(
26
+ model_spec,
27
+ name: robot_name,
28
+ system_prompt: system_prompt,
29
+ local_tools: ToolLoader.filtered_tools(config),
30
+ mcp_servers: RobotFactory.mcp_server_configs(config),
31
+ on_content: nil,
32
+ config: RobotFactory.build_run_config(config)
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ # Local thinking-mode models (e.g. qwen3 on Ollama) sometimes generate
39
+ # reasoning content but leave the response section empty, causing the user
40
+ # to receive only raw thinking text. This instruction nudges the model to
41
+ # always emit a plain-text answer after its reasoning.
42
+ def thinking_mode_instruction(model_spec)
43
+ return nil unless model_spec&.local_provider?
44
+
45
+ "After completing your reasoning, always write a clear, complete response " \
46
+ "in plain text. Never leave your response empty after thinking — your " \
47
+ "answer must appear outside the thinking block."
48
+ end
49
+ end
50
+ end
51
+ end