aia 1.1.1 → 2.0.0.0.pre.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +231 -0
  4. data/.quality/flay_baseline.txt +1 -0
  5. data/.quality/flog_baseline.txt +29 -0
  6. data/.quality/reek_baseline.txt +80 -0
  7. data/.rubocop.yml +116 -0
  8. data/.version +1 -1
  9. data/CHANGELOG.md +259 -50
  10. data/IMPLEMENTATION_PLAN.md +506 -0
  11. data/README.md +266 -238
  12. data/Rakefile +118 -5
  13. data/architecture_review.md +314 -0
  14. data/bin/aia +16 -0
  15. data/docs/AGENTS.md +40 -0
  16. data/docs/advanced-prompting.md +67 -3
  17. data/docs/cli-reference.md +312 -56
  18. data/docs/configuration.md +130 -19
  19. data/docs/contributing.md +56 -2
  20. data/docs/directives-reference.md +593 -78
  21. data/docs/faq.md +85 -3
  22. data/docs/guides/available-models.md +1 -1
  23. data/docs/guides/basic-usage.md +6 -6
  24. data/docs/guides/chat.md +40 -16
  25. data/docs/guides/crew.md +239 -0
  26. data/docs/guides/executable-prompts.md +1 -1
  27. data/docs/guides/index.md +1 -0
  28. data/docs/guides/models.md +15 -0
  29. data/docs/index.md +29 -2
  30. data/docs/installation.md +44 -17
  31. data/docs/mcp-integration.md +40 -0
  32. data/docs/prompt_management.md +85 -86
  33. data/docs/security.md +47 -0
  34. data/docs/special_projects_guide.md +386 -0
  35. data/docs/tools-and-mcp-examples.md +23 -0
  36. data/docs/workflows-and-pipelines.md +84 -7
  37. data/examples/.gitignore +1 -0
  38. data/examples/00_setup_aia.sh +27 -44
  39. data/examples/11_multi_model.sh +4 -14
  40. data/examples/12_token_usage.sh +3 -12
  41. data/examples/18_tools.sh +10 -2
  42. data/examples/22_chat_mode.sh +0 -10
  43. data/examples/23_verify.sh +139 -0
  44. data/examples/24_decompose.sh +139 -0
  45. data/examples/25_spawn.sh +139 -0
  46. data/examples/26_debate.sh +97 -0
  47. data/examples/27_mention_routing.sh +157 -0
  48. data/examples/28_model_switching.sh +106 -0
  49. data/examples/29_agent_harness.sh +177 -0
  50. data/examples/README.md +65 -0
  51. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  52. data/examples/aia_config.yml +1 -1
  53. data/examples/aia_config_orchestrator.yml +45 -0
  54. data/examples/common.sh +18 -6
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  57. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  58. data/examples/rules/01_classify_ruby.rb +16 -0
  59. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  60. data/examples/rules/03_gate_prompt_length.rb +19 -0
  61. data/examples/rules/04_tool_selection.rb +41 -0
  62. data/examples/rules/README.md +30 -0
  63. data/examples/run_all.sh +48 -15
  64. data/examples/tools/word_count_tool.rb +1 -1
  65. data/lib/AGENTS.md +57 -0
  66. data/lib/aia/chat_loop.rb +304 -164
  67. data/lib/aia/config/cli_parser.rb +174 -111
  68. data/lib/aia/config/defaults.yml +62 -33
  69. data/lib/aia/config/mcp_parser.rb +39 -46
  70. data/lib/aia/config/model_spec.rb +34 -2
  71. data/lib/aia/config/validator.rb +108 -142
  72. data/lib/aia/config.rb +110 -145
  73. data/lib/aia/content_extractor.rb +153 -0
  74. data/lib/aia/cost_calculator.rb +38 -0
  75. data/lib/aia/crew.rb +164 -0
  76. data/lib/aia/debate_handler.rb +166 -0
  77. data/lib/aia/delegate_handler.rb +112 -0
  78. data/lib/aia/directive.rb +33 -18
  79. data/lib/aia/directive_processor.rb +16 -7
  80. data/lib/aia/directives/configuration_directives.rb +160 -20
  81. data/lib/aia/directives/context_directives.rb +38 -26
  82. data/lib/aia/directives/execution_directives.rb +136 -4
  83. data/lib/aia/directives/model_directives.rb +76 -34
  84. data/lib/aia/directives/trakflow_directives.rb +44 -0
  85. data/lib/aia/directives/utility_directives.rb +203 -6
  86. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  87. data/lib/aia/errors.rb +15 -0
  88. data/lib/aia/fact_asserter.rb +27 -0
  89. data/lib/aia/fzf.rb +9 -31
  90. data/lib/aia/handler_context.rb +17 -0
  91. data/lib/aia/handler_protocol.rb +19 -0
  92. data/lib/aia/history_transfer.rb +55 -0
  93. data/lib/aia/input_collector.rb +3 -3
  94. data/lib/aia/layered_orchestrator.rb +448 -0
  95. data/lib/aia/logger.rb +24 -4
  96. data/lib/aia/mcp_config_normalizer.rb +35 -0
  97. data/lib/aia/mcp_connection_manager.rb +305 -0
  98. data/lib/aia/mcp_discovery.rb +44 -0
  99. data/lib/aia/mcp_grouper.rb +33 -0
  100. data/lib/aia/mcp_utility.rb +57 -0
  101. data/lib/aia/mention_router.rb +260 -0
  102. data/lib/aia/model_alias_registry.rb +97 -0
  103. data/lib/aia/model_switch_handler.rb +100 -0
  104. data/lib/aia/network_builder.rb +155 -0
  105. data/lib/aia/network_memory_manager.rb +55 -0
  106. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  107. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  108. data/lib/aia/pipeline_orchestrator.rb +262 -0
  109. data/lib/aia/plugin_loader.rb +170 -0
  110. data/lib/aia/plugin_monitor.rb +208 -0
  111. data/lib/aia/prompt_decomposer.rb +157 -0
  112. data/lib/aia/prompt_handler.rb +19 -39
  113. data/lib/aia/robot_builder.rb +51 -0
  114. data/lib/aia/robot_factory.rb +334 -0
  115. data/lib/aia/robot_namer.rb +116 -0
  116. data/lib/aia/session.rb +83 -17
  117. data/lib/aia/session_tracker.rb +209 -0
  118. data/lib/aia/similarity_scorer.rb +39 -0
  119. data/lib/aia/skill_utils.rb +105 -1
  120. data/lib/aia/spawn_handler.rb +129 -0
  121. data/lib/aia/spawn_spec_parser.rb +65 -0
  122. data/lib/aia/special_mode_handler.rb +302 -0
  123. data/lib/aia/startup_coordinator.rb +150 -0
  124. data/lib/aia/streaming_runner.rb +169 -0
  125. data/lib/aia/system_prompt_assembler.rb +88 -0
  126. data/lib/aia/task_coordinator.rb +202 -0
  127. data/lib/aia/task_decomposer.rb +57 -0
  128. data/lib/aia/task_executor.rb +51 -0
  129. data/lib/aia/tfidf_math.rb +27 -0
  130. data/lib/aia/tool_filter/tfidf.rb +116 -0
  131. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  132. data/lib/aia/tool_filter.rb +82 -0
  133. data/lib/aia/tool_filter_registry.rb +30 -0
  134. data/lib/aia/tool_filter_strategy.rb +143 -0
  135. data/lib/aia/tool_loader.rb +210 -0
  136. data/lib/aia/tool_utility.rb +30 -0
  137. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  138. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  139. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  140. data/lib/aia/tools/task_board_tool.rb +114 -0
  141. data/lib/aia/trakflow_bridge.rb +173 -0
  142. data/lib/aia/turn_state.rb +94 -0
  143. data/lib/aia/ui_presenter.rb +166 -198
  144. data/lib/aia/utility.rb +134 -87
  145. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  146. data/lib/aia/verification_network.rb +58 -0
  147. data/lib/aia.rb +108 -63
  148. data/mkdocs.yml +1 -0
  149. metadata +179 -56
  150. data/justfile +0 -215
  151. data/lib/aia/adapter/chat_execution.rb +0 -242
  152. data/lib/aia/adapter/error_handler.rb +0 -68
  153. data/lib/aia/adapter/gem_activator.rb +0 -57
  154. data/lib/aia/adapter/mcp_connector.rb +0 -274
  155. data/lib/aia/adapter/modality_handlers.rb +0 -167
  156. data/lib/aia/adapter/model_registry.rb +0 -81
  157. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  158. data/lib/aia/adapter/provider_configurator.rb +0 -59
  159. data/lib/aia/adapter/tool_filter.rb +0 -85
  160. data/lib/aia/adapter/tool_loader.rb +0 -90
  161. data/lib/aia/chat_processor_service.rb +0 -178
  162. data/lib/aia/prompt_pipeline.rb +0 -183
  163. data/lib/aia/ruby_llm_adapter.rb +0 -95
  164. data/lib/extensions/openstruct_merge.rb +0 -48
  165. data/lib/extensions/ruby_llm/.irbrc +0 -56
  166. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  167. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  168. data/lib/refinements/string.rb +0 -16
  169. data/main.just +0 -76
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/prompt_decomposer.rb
4
+ #
5
+ # Decomposes complex prompts into independent sub-tasks.
6
+ # A coordinator robot breaks them down, specialist robots execute,
7
+ # and the coordinator reassembles.
8
+
9
+ require 'json'
10
+
11
+ module AIA
12
+ class PromptDecomposer
13
+ include ContentExtractor
14
+
15
+ DECOMPOSITION_PROMPT = <<~PROMPT
16
+ Analyze this user request and determine if it can be broken into independent sub-tasks.
17
+
18
+ Rules:
19
+ - Only decompose if sub-tasks are truly independent (can run in parallel)
20
+ - Each sub-task should be self-contained
21
+ - 2-5 sub-tasks maximum
22
+ - Keep sub-task descriptions clear and specific
23
+ - Use an empty subtasks array if the request cannot be meaningfully decomposed
24
+
25
+ User request: %{prompt}
26
+ PROMPT
27
+
28
+ # Appended to DECOMPOSITION_PROMPT when the model does not support
29
+ # structured output and with_schema cannot be used.
30
+ FALLBACK_JSON_INSTRUCTION = <<~INSTRUCTION
31
+
32
+ Respond with ONLY a JSON object — no explanation, no markdown, no prose:
33
+ {"subtasks": ["sub-task description 1", "sub-task description 2"]}
34
+ INSTRUCTION
35
+
36
+ # JSON schema for structured-output-capable models.
37
+ # Uses a wrapper object because Claude requires a top-level object.
38
+ SUBTASKS_SCHEMA = {
39
+ name: 'subtask_decomposition',
40
+ schema: {
41
+ type: 'object',
42
+ properties: {
43
+ subtasks: {
44
+ type: 'array',
45
+ items: { type: 'string' },
46
+ description: 'Independent sub-task descriptions. Empty array if not decomposable.'
47
+ }
48
+ },
49
+ required: ['subtasks'],
50
+ additionalProperties: false
51
+ },
52
+ strict: true
53
+ }.freeze
54
+
55
+ SYNTHESIS_TEMPLATE = <<~PROMPT
56
+ Original request: %{prompt}
57
+
58
+ Sub-task results:
59
+ %{results}
60
+
61
+ Synthesize these results into a coherent final response that fully addresses the original request.
62
+ PROMPT
63
+
64
+ def initialize(robot)
65
+ @robot = robot
66
+ end
67
+
68
+ # Attempt to decompose a prompt into sub-tasks.
69
+ #
70
+ # Uses a temporary probe robot so the decomposition prompt and response do
71
+ # not pollute the main conversation history. When the configured model
72
+ # supports structured output (structured_output? == true) with_schema is
73
+ # used to get a guaranteed Hash back. Otherwise a JSON instruction is
74
+ # appended to the prompt and the text response is parsed manually.
75
+ #
76
+ # @param prompt [String] the user's prompt
77
+ # @return [Array<String>] sub-task descriptions (empty if not decomposable)
78
+ def decompose(prompt)
79
+ probe = build_probe_robot
80
+ use_schema = structured_output?
81
+
82
+ if use_schema
83
+ probe.with_schema(SUBTASKS_SCHEMA)
84
+ full_prompt = DECOMPOSITION_PROMPT % { prompt: prompt }
85
+ else
86
+ AIA.logger.warn("PromptDecomposer: configured model does not support structured output — using JSON prompt fallback")
87
+ full_prompt = (DECOMPOSITION_PROMPT + FALLBACK_JSON_INSTRUCTION) % { prompt: prompt }
88
+ end
89
+
90
+ result = probe.run(full_prompt, mcp: :none, tools: :none)
91
+ content = extract_content(result)
92
+ AIA.logger.debug("PromptDecomposer#decompose content class=#{content.class}")
93
+ subtasks = extract_subtasks(content)
94
+ subtasks.is_a?(Array) ? subtasks.select { |t| t.is_a?(String) && !t.empty? } : []
95
+ rescue StandardError => e
96
+ AIA.logger.warn("PromptDecomposer#decompose failed: #{e.class}: #{e.message}")
97
+ []
98
+ end
99
+
100
+ # Synthesize sub-task results into a final response.
101
+ #
102
+ # @param prompt [String] the original user prompt
103
+ # @param results [Array<String>] results from each sub-task
104
+ # @return the synthesized response
105
+ def synthesize(prompt, results)
106
+ formatted_results = results.each_with_index.map do |r, i|
107
+ "Sub-task #{i + 1}:\n#{r}"
108
+ end.join("\n\n")
109
+
110
+ @robot.run(
111
+ SYNTHESIS_TEMPLATE % { prompt: prompt, results: formatted_results },
112
+ mcp: :none, tools: :none
113
+ )
114
+ end
115
+
116
+ private
117
+
118
+ def structured_output?
119
+ RubyLLM.models.find(AIA.config.models.first.internal_id).structured_output?
120
+ rescue StandardError
121
+ false
122
+ end
123
+
124
+ # Build a fresh temporary robot for the decomposition probe.
125
+ # This robot has no conversation history and is discarded after use,
126
+ # keeping @robot's history clean for the normal or synthesis path.
127
+ def build_probe_robot
128
+ config = AIA.config
129
+ run_config = RobotFactory.build_run_config(config)
130
+ RobotLab.build(
131
+ name: "decompose-probe",
132
+ model: config.models.first.internal_id,
133
+ system_prompt: nil,
134
+ config: run_config
135
+ )
136
+ end
137
+
138
+ # Extract the subtasks array from the probe response.
139
+ # Hash path: with_schema caused ruby_llm to auto-parse the JSON response.
140
+ # String path: fallback text parsing for models without structured output.
141
+ def extract_subtasks(content)
142
+ case content
143
+ when Hash
144
+ content['subtasks'] || content[:subtasks] || []
145
+ when Array
146
+ content
147
+ when String
148
+ parsed = JSON.parse(content.gsub(/```(?:json)?\s*/i, '').gsub('```', '').strip)
149
+ parsed.is_a?(Hash) ? parsed['subtasks'] || parsed[:subtasks] || [] : parsed
150
+ else
151
+ []
152
+ end
153
+ rescue JSON::ParserError
154
+ []
155
+ end
156
+ end
157
+ end
@@ -3,15 +3,14 @@
3
3
  require 'pm'
4
4
  require 'erb'
5
5
 
6
-
7
6
  module AIA
8
7
  class PromptHandler
9
8
  include AIA::SkillUtils
10
9
 
11
10
  # Struct for path-based role content (bypasses PM parsing)
12
11
  RoleContent = Struct.new(:content) do
13
- def to_s; content; end
14
- def metadata; nil; end
12
+ def to_s = content
13
+ def metadata = nil
15
14
  end
16
15
 
17
16
  # Root-level YAML keys that are shorthands for deeper config paths
@@ -25,7 +24,7 @@ module AIA
25
24
  'next' => [%w[config next], %w[config pipeline]],
26
25
  'pipeline' => [%w[config pipeline], %w[config next]],
27
26
  'shell' => [%w[config shell], %w[config flags shell]],
28
- 'erb' => [%w[config erb], %w[config flags erb]],
27
+ 'erb' => [%w[config erb], %w[config flags erb]]
29
28
  }.freeze
30
29
 
31
30
  def initialize
@@ -39,7 +38,6 @@ module AIA
39
38
  register_pm_directives
40
39
  end
41
40
 
42
-
43
41
  def fetch_prompt(prompt_id)
44
42
  if prompt_id == '__FUZZY_SEARCH__'
45
43
  return fuzzy_search_prompt('')
@@ -54,7 +52,7 @@ module AIA
54
52
  parsed = if File.exist?(prompt_file_path)
55
53
  PM.parse(prompt_id)
56
54
  else
57
- warn "Warning: Invalid prompt ID or file not found: #{prompt_id}"
55
+ $stderr.puts "Warning: Invalid prompt ID or file not found: #{prompt_id}"
58
56
  logger.warn("Invalid prompt ID or file not found: #{prompt_id}")
59
57
  handle_missing_prompt(prompt_id)
60
58
  end
@@ -63,7 +61,6 @@ module AIA
63
61
  parsed
64
62
  end
65
63
 
66
-
67
64
  def fetch_role(role_id)
68
65
  return handle_missing_role("roles/") if role_id.nil?
69
66
  return fetch_role_from_path(role_id) if path_based_id?(role_id)
@@ -79,7 +76,7 @@ module AIA
79
76
  # and returns the ID string as content. Parse from raw file content instead.
80
77
  PM.parse_string(File.read(role_file_path))
81
78
  else
82
- warn "Warning: Invalid role ID or file not found: #{role_id}"
79
+ $stderr.puts "Warning: Invalid role ID or file not found: #{role_id}"
83
80
  logger.warn("Invalid role ID or file not found: #{role_id}")
84
81
  handle_missing_role(role_id)
85
82
  end
@@ -88,7 +85,6 @@ module AIA
88
85
  parsed
89
86
  end
90
87
 
91
-
92
88
  # Load role for a specific model (ADR-005)
93
89
  # Takes a model spec hash and default role, returns rendered role text
94
90
  def load_role_for_model(model_spec, default_role = nil)
@@ -103,12 +99,11 @@ module AIA
103
99
  role_parsed = fetch_role(role_id)
104
100
  role_parsed.to_s
105
101
  rescue => e
106
- warn "Warning: Could not load role '#{role_id}' for model: #{e.message}"
102
+ $stderr.puts "Warning: Could not load role '#{role_id}' for model: #{e.message}"
107
103
  logger.warn("Could not load role '#{role_id}' for model: #{e.message}")
108
104
  nil
109
105
  end
110
106
 
111
-
112
107
  # Applies YAML front matter metadata to AIA.config.
113
108
  # Processes root-level shorthand keys, detects conflicts with config: section,
114
109
  # and deep merges the config: section into AIA.config.
@@ -132,10 +127,8 @@ module AIA
132
127
  deep_merge_config(config_section) if config_section
133
128
  end
134
129
 
135
-
136
130
  private
137
131
 
138
-
139
132
  def fetch_executable_prompt
140
133
  content = AIA.config.executable_prompt_content
141
134
  parsed = PM.parse(content)
@@ -143,18 +136,16 @@ module AIA
143
136
  parsed
144
137
  end
145
138
 
146
-
147
139
  def register_pm_directives
148
140
  AIA::Directive.register_all
149
141
  end
150
142
 
151
-
152
143
  # Detect conflicts between root shorthand keys and the config: section.
153
144
  # Raises AIA::ConfigurationError when the same setting is specified in both places.
154
145
  def detect_shorthand_conflicts(meta_hash, config_section)
155
146
  # 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)
147
+ has_next = meta_hash.key?('next') || meta_hash.key?(:next)
148
+ has_pipeline = meta_hash.key?('pipeline') || meta_hash.key?(:pipeline)
158
149
 
159
150
  if has_next && has_pipeline
160
151
  raise ConfigurationError, "Both 'next' and 'pipeline' specified at root level — they are mutually exclusive"
@@ -173,14 +164,14 @@ module AIA
173
164
 
174
165
  unless value.nil?
175
166
  raise ConfigurationError,
176
- "Conflict: '#{root_key}' at root level and '#{path.join('.')}' in config section"
167
+ "Conflict: '#{root_key}' at root level and '#{path.join('.')}' in config section"
177
168
  end
178
169
  end
179
170
  end
180
171
  end
181
172
 
182
-
183
173
  # Apply root-level shorthand keys to AIA.config
174
+ # rubocop:disable Metrics/AbcSize
184
175
  def apply_root_shorthands(meta_hash)
185
176
  # model → AIA.config.models (replace with single-model array)
186
177
  model_val = meta_hash['model'] || meta_hash[:model]
@@ -226,11 +217,10 @@ module AIA
226
217
 
227
218
  # erb → AIA.config.flags.erb (and PM's erb via metadata)
228
219
  erb_val = meta_hash['erb'] || meta_hash[:erb]
229
- unless erb_val.nil?
230
- AIA.config.flags.erb = erb_val
231
- end
220
+ return if erb_val.nil?
221
+ AIA.config.flags.erb = erb_val
232
222
  end
233
-
223
+ # rubocop:enable Metrics/AbcSize
234
224
 
235
225
  def logger
236
226
  @logger ||= LoggerManager.aia_logger
@@ -253,7 +243,6 @@ module AIA
253
243
  end
254
244
  end
255
245
 
256
-
257
246
  # Recursively merge a hash into a config object
258
247
  def deep_merge_into_config(config_obj, hash)
259
248
  hash.each do |key, value|
@@ -266,7 +255,6 @@ module AIA
266
255
  end
267
256
  end
268
257
 
269
-
270
258
  # Dig into a hash with an array of keys, returning nil if any key is missing
271
259
  def dig_hash(hash, keys)
272
260
  keys.reduce(hash) do |h, key|
@@ -275,7 +263,6 @@ module AIA
275
263
  end
276
264
  end
277
265
 
278
-
279
266
  # Recursively symbolize all keys in a hash
280
267
  def symbolize_keys_deep(obj)
281
268
  case obj
@@ -290,23 +277,21 @@ module AIA
290
277
  end
291
278
  end
292
279
 
293
-
294
280
  def handle_missing_prompt(prompt_id)
295
281
  prompt_id = prompt_id.to_s.strip
296
282
  if prompt_id.empty?
297
- warn "Error: Prompt ID cannot be empty"
283
+ $stderr.puts "Error: Prompt ID cannot be empty"
298
284
  exit 1
299
285
  end
300
286
 
301
287
  if AIA.config.flags.fuzzy
302
288
  fuzzy_search_prompt(prompt_id)
303
289
  else
304
- warn "Error: Could not find prompt with ID: #{prompt_id}"
290
+ $stderr.puts "Error: Could not find prompt with ID: #{prompt_id}"
305
291
  exit 1
306
292
  end
307
293
  end
308
294
 
309
-
310
295
  def fetch_role_from_path(role_id)
311
296
  expanded = File.expand_path(role_id)
312
297
  expanded += '.md' if File.extname(expanded).empty?
@@ -320,7 +305,6 @@ module AIA
320
305
  RoleContent.new(File.read(expanded))
321
306
  end
322
307
 
323
-
324
308
  def fuzzy_search_prompt(prompt_id)
325
309
  new_prompt_id = search_prompt_id_with_fzf(prompt_id)
326
310
 
@@ -331,23 +315,21 @@ module AIA
331
315
  PM.parse(new_prompt_id)
332
316
  end
333
317
 
334
-
335
318
  def handle_missing_role(role_id)
336
319
  role_id = role_id.to_s.strip
337
320
  if role_id.empty? || role_id == "roles/"
338
- warn "Error: Role ID cannot be empty"
321
+ $stderr.puts "Error: Role ID cannot be empty"
339
322
  exit 1
340
323
  end
341
324
 
342
325
  if AIA.config.flags.fuzzy
343
326
  fuzzy_search_role(role_id)
344
327
  else
345
- warn "Error: Could not find role with ID: #{role_id}"
328
+ $stderr.puts "Error: Could not find role with ID: #{role_id}"
346
329
  exit 1
347
330
  end
348
331
  end
349
332
 
350
-
351
333
  def fuzzy_search_role(role_id)
352
334
  new_role_id = search_role_id_with_fzf(role_id)
353
335
  if new_role_id.nil? || new_role_id.empty?
@@ -357,10 +339,9 @@ module AIA
357
339
  PM.parse(new_role_id)
358
340
  end
359
341
 
360
-
361
342
  def search_prompt_id_with_fzf(initial_query)
362
343
  prompt_files = Dir.glob(File.join(@prompts_dir, "*#{AIA.config.prompts.extname}"))
363
- .map { |file| File.basename(file, AIA.config.prompts.extname) }
344
+ .map { |file| File.basename(file, AIA.config.prompts.extname) }
364
345
  fzf = AIA::Fzf.new(
365
346
  list: prompt_files,
366
347
  directory: @prompts_dir,
@@ -371,10 +352,9 @@ module AIA
371
352
  fzf.run || (raise "No prompt ID selected")
372
353
  end
373
354
 
374
-
375
355
  def search_role_id_with_fzf(initial_query)
376
356
  role_files = Dir.glob(File.join(@roles_dir, "*#{AIA.config.prompts.extname}"))
377
- .map { |file| File.basename(file, AIA.config.prompts.extname) }
357
+ .map { |file| File.basename(file, AIA.config.prompts.extname) }
378
358
  fzf = AIA::Fzf.new(
379
359
  list: role_files,
380
360
  directory: @prompts_dir,
@@ -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