aia 1.1.0 → 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 (170) 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 +266 -42
  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 +19 -0
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/project_summary +2 -2
  57. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  58. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  59. data/examples/rules/01_classify_ruby.rb +16 -0
  60. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  61. data/examples/rules/03_gate_prompt_length.rb +19 -0
  62. data/examples/rules/04_tool_selection.rb +41 -0
  63. data/examples/rules/README.md +30 -0
  64. data/examples/run_all.sh +48 -15
  65. data/examples/tools/word_count_tool.rb +1 -1
  66. data/lib/AGENTS.md +57 -0
  67. data/lib/aia/chat_loop.rb +306 -159
  68. data/lib/aia/config/cli_parser.rb +174 -111
  69. data/lib/aia/config/defaults.yml +62 -33
  70. data/lib/aia/config/mcp_parser.rb +39 -46
  71. data/lib/aia/config/model_spec.rb +34 -2
  72. data/lib/aia/config/validator.rb +121 -138
  73. data/lib/aia/config.rb +110 -145
  74. data/lib/aia/content_extractor.rb +153 -0
  75. data/lib/aia/cost_calculator.rb +38 -0
  76. data/lib/aia/crew.rb +164 -0
  77. data/lib/aia/debate_handler.rb +166 -0
  78. data/lib/aia/delegate_handler.rb +112 -0
  79. data/lib/aia/directive.rb +33 -18
  80. data/lib/aia/directive_processor.rb +16 -7
  81. data/lib/aia/directives/configuration_directives.rb +160 -20
  82. data/lib/aia/directives/context_directives.rb +38 -26
  83. data/lib/aia/directives/execution_directives.rb +136 -4
  84. data/lib/aia/directives/model_directives.rb +76 -34
  85. data/lib/aia/directives/trakflow_directives.rb +44 -0
  86. data/lib/aia/directives/utility_directives.rb +203 -6
  87. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  88. data/lib/aia/errors.rb +15 -0
  89. data/lib/aia/fact_asserter.rb +27 -0
  90. data/lib/aia/fzf.rb +9 -31
  91. data/lib/aia/handler_context.rb +17 -0
  92. data/lib/aia/handler_protocol.rb +19 -0
  93. data/lib/aia/history_transfer.rb +55 -0
  94. data/lib/aia/input_collector.rb +3 -3
  95. data/lib/aia/layered_orchestrator.rb +448 -0
  96. data/lib/aia/logger.rb +24 -4
  97. data/lib/aia/mcp_config_normalizer.rb +35 -0
  98. data/lib/aia/mcp_connection_manager.rb +305 -0
  99. data/lib/aia/mcp_discovery.rb +44 -0
  100. data/lib/aia/mcp_grouper.rb +33 -0
  101. data/lib/aia/mcp_utility.rb +57 -0
  102. data/lib/aia/mention_router.rb +260 -0
  103. data/lib/aia/model_alias_registry.rb +97 -0
  104. data/lib/aia/model_switch_handler.rb +100 -0
  105. data/lib/aia/network_builder.rb +155 -0
  106. data/lib/aia/network_memory_manager.rb +55 -0
  107. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  108. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  109. data/lib/aia/pipeline_orchestrator.rb +262 -0
  110. data/lib/aia/plugin_loader.rb +170 -0
  111. data/lib/aia/plugin_monitor.rb +208 -0
  112. data/lib/aia/prompt_decomposer.rb +157 -0
  113. data/lib/aia/prompt_handler.rb +19 -39
  114. data/lib/aia/robot_builder.rb +51 -0
  115. data/lib/aia/robot_factory.rb +334 -0
  116. data/lib/aia/robot_namer.rb +116 -0
  117. data/lib/aia/session.rb +83 -17
  118. data/lib/aia/session_tracker.rb +209 -0
  119. data/lib/aia/similarity_scorer.rb +39 -0
  120. data/lib/aia/skill_utils.rb +105 -1
  121. data/lib/aia/spawn_handler.rb +129 -0
  122. data/lib/aia/spawn_spec_parser.rb +65 -0
  123. data/lib/aia/special_mode_handler.rb +302 -0
  124. data/lib/aia/startup_coordinator.rb +150 -0
  125. data/lib/aia/streaming_runner.rb +169 -0
  126. data/lib/aia/system_prompt_assembler.rb +88 -0
  127. data/lib/aia/task_coordinator.rb +202 -0
  128. data/lib/aia/task_decomposer.rb +57 -0
  129. data/lib/aia/task_executor.rb +51 -0
  130. data/lib/aia/tfidf_math.rb +27 -0
  131. data/lib/aia/tool_filter/tfidf.rb +116 -0
  132. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  133. data/lib/aia/tool_filter.rb +82 -0
  134. data/lib/aia/tool_filter_registry.rb +30 -0
  135. data/lib/aia/tool_filter_strategy.rb +143 -0
  136. data/lib/aia/tool_loader.rb +210 -0
  137. data/lib/aia/tool_utility.rb +30 -0
  138. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  139. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  140. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  141. data/lib/aia/tools/task_board_tool.rb +114 -0
  142. data/lib/aia/trakflow_bridge.rb +173 -0
  143. data/lib/aia/turn_state.rb +94 -0
  144. data/lib/aia/ui_presenter.rb +166 -198
  145. data/lib/aia/utility.rb +134 -87
  146. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  147. data/lib/aia/verification_network.rb +58 -0
  148. data/lib/aia.rb +108 -63
  149. data/mkdocs.yml +1 -0
  150. metadata +179 -56
  151. data/justfile +0 -215
  152. data/lib/aia/adapter/chat_execution.rb +0 -242
  153. data/lib/aia/adapter/error_handler.rb +0 -68
  154. data/lib/aia/adapter/gem_activator.rb +0 -57
  155. data/lib/aia/adapter/mcp_connector.rb +0 -274
  156. data/lib/aia/adapter/modality_handlers.rb +0 -167
  157. data/lib/aia/adapter/model_registry.rb +0 -81
  158. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  159. data/lib/aia/adapter/provider_configurator.rb +0 -59
  160. data/lib/aia/adapter/tool_filter.rb +0 -85
  161. data/lib/aia/adapter/tool_loader.rb +0 -90
  162. data/lib/aia/chat_processor_service.rb +0 -164
  163. data/lib/aia/prompt_pipeline.rb +0 -183
  164. data/lib/aia/ruby_llm_adapter.rb +0 -95
  165. data/lib/extensions/openstruct_merge.rb +0 -48
  166. data/lib/extensions/ruby_llm/.irbrc +0 -56
  167. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  168. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  169. data/lib/refinements/string.rb +0 -16
  170. data/main.just +0 -76
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/session_tracker.rb
4
+ #
5
+ # Tracks session metrics and outcomes for the learning loop.
6
+ # Records per-turn data: model, tokens, cost, latency, decisions.
7
+
8
+ module AIA
9
+ class SessionTracker
10
+ attr_reader :turn_count, :total_cost, :total_tokens, :turns
11
+
12
+ def initialize
13
+ @turn_count = 0
14
+ @total_cost = 0.0
15
+ @total_tokens = 0
16
+ @turns = []
17
+ end
18
+
19
+ # Record a completed turn.
20
+ #
21
+ # For network results (SimpleFlow::Result), records one entry per
22
+ # robot so the /cost directive can show per-model breakdowns.
23
+ #
24
+ # @param model [String] model used (ignored for network results)
25
+ # @param input [String] user input
26
+ # @param result the LLM response
27
+ # @param decisions [Hash, nil] routing decisions for this turn
28
+ # @param elapsed [Float, nil] seconds the model took to respond
29
+ def record_turn(model:, input:, result:, decisions: nil, elapsed: nil)
30
+ if defined?(SimpleFlow::Result) && result.is_a?(SimpleFlow::Result)
31
+ record_network_turn(input: input, flow_result: result, decisions: decisions, elapsed: elapsed)
32
+ return
33
+ end
34
+
35
+ @turn_count += 1
36
+
37
+ metrics = extract_metrics(result)
38
+ @total_cost += metrics[:cost]
39
+ @total_tokens += metrics[:tokens]
40
+
41
+ @turns << {
42
+ model: model,
43
+ input_length: input.to_s.length,
44
+ input_tokens: metrics[:input_tokens],
45
+ output_tokens: metrics[:output_tokens],
46
+ tokens: metrics[:tokens],
47
+ cost: metrics[:cost],
48
+ elapsed: elapsed || 0,
49
+ decisions: decisions&.to_h,
50
+ timestamp: Time.now
51
+ }
52
+ end
53
+
54
+ # Record a model switch event.
55
+ #
56
+ # @param from [String] previous model
57
+ # @param to [String] new model
58
+ # @param reason [String] reason for the switch
59
+ def record_model_switch(from:, to:, reason: "user_request")
60
+ @turns << {
61
+ type: :model_switch,
62
+ from: from,
63
+ to: to,
64
+ reason: reason,
65
+ timestamp: Time.now
66
+ }
67
+ end
68
+
69
+ # Record user feedback on the last response.
70
+ #
71
+ # @param satisfied [Boolean] whether the user was satisfied
72
+ def record_user_feedback(satisfied:)
73
+ return if @turns.empty?
74
+ @turns.last[:user_satisfied] = satisfied
75
+ end
76
+
77
+ # Export session stats as a hash for KBS fact assertion.
78
+ #
79
+ # @return [Hash] session statistics
80
+ def to_facts
81
+ {
82
+ turn_count: @turn_count,
83
+ total_cost: @total_cost,
84
+ total_tokens: @total_tokens
85
+ }
86
+ end
87
+
88
+ # Reset all tracked data.
89
+ def reset!
90
+ @turn_count = 0
91
+ @total_cost = 0.0
92
+ @total_tokens = 0
93
+ @turns.clear
94
+ end
95
+
96
+ private
97
+
98
+ # Expand a network SimpleFlow::Result into one turn entry per robot.
99
+ # Computes TF-IDF similarity of each response against the first.
100
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
101
+ def record_network_turn(input:, flow_result:, decisions: nil, elapsed: nil)
102
+ @turn_count += 1
103
+ now = Time.now
104
+
105
+ # Collect robot data in order for similarity scoring
106
+ robot_entries = []
107
+ response_texts = []
108
+
109
+ # rubocop:disable Metrics/BlockLength
110
+ flow_result.context.each do |task_name, robot_result|
111
+ next if task_name == :run_params
112
+ next unless robot_result.respond_to?(:raw)
113
+
114
+ raw = robot_result.raw
115
+ input_tokens = (raw.respond_to?(:input_tokens) && raw.input_tokens) || 0
116
+ output_tokens = (raw.respond_to?(:output_tokens) && raw.output_tokens) || 0
117
+ tokens = input_tokens + output_tokens
118
+
119
+ model_id = extract_model_id_from_raw(raw)
120
+ model_id ||= robot_result.respond_to?(:robot_name) ? robot_result.robot_name : task_name.to_s
121
+
122
+ cost = tokens.positive? ? compute_cost_for_model(model_id, input_tokens, output_tokens) : 0.0
123
+ robot_elapsed = robot_result.respond_to?(:duration) ? (robot_result.duration || 0) : 0
124
+
125
+ text = if robot_result.respond_to?(:reply)
126
+ robot_result.reply.to_s
127
+ elsif robot_result.respond_to?(:content)
128
+ robot_result.content.to_s
129
+ else
130
+ ""
131
+ end
132
+ # rubocop:enable Metrics/BlockLength
133
+ response_texts << text
134
+
135
+ robot_entries << {
136
+ model: model_id,
137
+ input_length: input.to_s.length,
138
+ input_tokens: input_tokens,
139
+ output_tokens: output_tokens,
140
+ tokens: tokens,
141
+ cost: cost,
142
+ elapsed: robot_elapsed,
143
+ decisions: decisions&.to_h,
144
+ timestamp: now
145
+ }
146
+ end
147
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
148
+
149
+ # Compute similarity scores (first model is reference)
150
+ scores = if robot_entries.size > 1
151
+ SimilarityScorer.score(response_texts)
152
+ else
153
+ Array.new(robot_entries.size)
154
+ end
155
+
156
+ robot_entries.each_with_index do |entry, i|
157
+ entry[:similarity] = scores[i]
158
+ @total_cost += entry[:cost]
159
+ @total_tokens += entry[:tokens]
160
+ @turns << entry
161
+ end
162
+ end
163
+
164
+ def extract_model_id_from_raw(raw)
165
+ return nil unless raw
166
+ return raw.model_id if raw.respond_to?(:model_id) && raw.model_id
167
+ return raw.model if raw.respond_to?(:model) && raw.model
168
+ nil
169
+ end
170
+
171
+ def compute_cost_for_model(model_id, input_tokens, output_tokens)
172
+ result = CostCalculator.calculate(model_id: model_id, input_tokens: input_tokens, output_tokens: output_tokens)
173
+ result[:available] ? result[:total_cost] : 0.0
174
+ end
175
+
176
+ def extract_metrics(result)
177
+ input_tokens = 0
178
+ output_tokens = 0
179
+
180
+ # Prefer raw RubyLLM::Message (has token data); fall back to output messages
181
+ source = if result.respond_to?(:raw) && result.raw.respond_to?(:input_tokens)
182
+ result.raw
183
+ elsif result.respond_to?(:output) && result.output.respond_to?(:last)
184
+ result.output.last
185
+ end
186
+
187
+ if source.respond_to?(:input_tokens) && source.input_tokens
188
+ input_tokens = source.input_tokens || 0
189
+ output_tokens = source.output_tokens || 0
190
+ end
191
+
192
+ tokens = input_tokens + output_tokens
193
+ cost = tokens.positive? ? compute_cost(result, input_tokens, output_tokens) : 0.0
194
+
195
+ { input_tokens: input_tokens, output_tokens: output_tokens, tokens: tokens, cost: cost }
196
+ end
197
+
198
+ # Compute cost from token counts and model pricing.
199
+ # Falls back to 0.0 if pricing info is unavailable.
200
+ def compute_cost(result, input_tokens, output_tokens)
201
+ model_id = if result.respond_to?(:robot_name)
202
+ result.robot_name
203
+ elsif result.respond_to?(:model_id)
204
+ result.model_id
205
+ end
206
+ compute_cost_for_model(model_id, input_tokens, output_tokens)
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/similarity_scorer.rb
4
+ #
5
+ # Computes TF-IDF cosine similarity between LLM responses.
6
+ # Uses the classifier gem's TF-IDF vectorizer with Porter stemming
7
+ # so that paraphrased responses ("focused" / "focuses") score high.
8
+
9
+ require 'classifier'
10
+ require_relative 'tfidf_math'
11
+
12
+ module AIA
13
+ class SimilarityScorer
14
+ # Compute pairwise similarity of each response against the first.
15
+ #
16
+ # @param responses [Array<String>] ordered response texts (first is reference)
17
+ # @return [Array<Float, nil>] similarity scores (nil for first, 0.0..1.0 for rest)
18
+ def self.score(responses)
19
+ return Array.new(responses.size) if responses.size < 2
20
+
21
+ texts = responses.map { |r| r.to_s.strip }
22
+ return Array.new(responses.size) if texts.first.empty?
23
+
24
+ tfidf = Classifier::TFIDF.new
25
+ tfidf.fit(texts)
26
+ vectors = texts.map { |t| tfidf.transform(t) }
27
+
28
+ vectors.each_with_index.map do |_vec, i|
29
+ if i.zero?
30
+ nil # reference model -- no comparison
31
+ else
32
+ AIA::TFIDFMath.cosine_similarity(vectors[0], vectors[i])
33
+ end
34
+ end
35
+ rescue StandardError
36
+ Array.new(responses.size)
37
+ end
38
+ end
39
+ end
@@ -51,9 +51,113 @@ module AIA
51
51
  content[(end_marker + 4)..].lstrip
52
52
  end
53
53
 
54
+ # Resolve the effective skills base directory from config.
55
+ # Skills live in config.skills.dir (set via --skills-dir, the -c config file,
56
+ # or the ~/.prompts/skills default). This is the single resolver used by both
57
+ # skill loading and `--list-skills`, so what is listed is always loadable.
58
+ #
59
+ # @param config [AIA::Config] the AIA configuration
60
+ # @return [String, nil] resolved base directory, or nil if not configured
61
+ def skills_base_dir(config)
62
+ config.skills&.dir
63
+ end
64
+
65
+ # Render the available skills as a markdown report — the body of --list-skills.
66
+ # Resolves the directory via skills_base_dir (same as loading) and respects a
67
+ # -c config file because it runs after config is built.
68
+ #
69
+ # @param config [AIA::Config]
70
+ # @return [String] markdown listing, or a "no skills" message
71
+ def list_skills_markdown(config)
72
+ dir = skills_base_dir(config)
73
+
74
+ unless dir && Dir.exist?(dir)
75
+ return "No skills directory found at #{dir}\n" \
76
+ "Create this directory and add skill subdirectories to use skills."
77
+ end
78
+
79
+ skill_ids = Dir.glob("*/SKILL.md", base: dir).map { |f| File.dirname(f) }.sort
80
+ if skill_ids.empty?
81
+ return "No skills found in #{dir}\n" \
82
+ "Create subdirectories with a SKILL.md file to define skills."
83
+ end
84
+
85
+ skill_ids.flat_map { |id| skill_markdown_entry(dir, id) }.join("\n")
86
+ end
87
+
88
+ # Markdown lines for a single skill entry (heading + front-matter table).
89
+ # Escapes pipe characters so values can't break the table.
90
+ #
91
+ # @return [Array<String>]
92
+ def skill_markdown_entry(dir, skill_id)
93
+ front_matter = parse_front_matter(File.join(dir, skill_id, 'SKILL.md'))
94
+ lines = ["## #{skill_id}", ""]
95
+
96
+ if front_matter.empty?
97
+ lines << "_No front matter found in SKILL.md_"
98
+ else
99
+ lines << "| Key | Value |" << "|-----|-------|"
100
+ front_matter.each do |key, value|
101
+ lines << "| #{key} | #{value.to_s.gsub('|', '\\|')} |"
102
+ end
103
+ end
104
+
105
+ lines << ""
106
+ lines
107
+ end
108
+
109
+ # Load and concatenate content from multiple skills.
110
+ # Used by both pipeline mode (prompt text injection) and chat mode
111
+ # (system prompt injection) to honour the --skill CLI option.
112
+ #
113
+ # @param skill_ids [Array<String>] skill names or path-based IDs
114
+ # @param skills_base_dir [String] base directory for named skills
115
+ # @return [String, nil] joined skill bodies, or nil if none loaded
116
+ def load_skills_content(skill_ids, skills_base_dir)
117
+ ids = Array(skill_ids).reject { |s| s.nil? || s.strip.empty? }
118
+ return nil if ids.empty?
119
+ return nil unless skills_base_dir && Dir.exist?(skills_base_dir)
120
+
121
+ contents = ids.filter_map { |id| load_single_skill_content(id, skills_base_dir) }
122
+ contents.empty? ? nil : contents.join("\n\n")
123
+ end
124
+
125
+ # Load the body of a single skill (front matter stripped).
126
+ # Handles both name-based (looks in skills_base_dir) and path-based IDs.
127
+ #
128
+ # @param skill_id [String] skill name or path
129
+ # @param skills_base_dir [String] base directory for named skills
130
+ # @return [String, nil] skill body text, or nil on error
131
+ def load_single_skill_content(skill_id, skills_base_dir)
132
+ skill_dir = find_skill_dir(skill_id, skills_base_dir)
133
+ unless skill_dir
134
+ $stderr.puts "Warning: Skill '#{skill_id}' not found in #{skills_base_dir}"
135
+ return nil
136
+ end
137
+
138
+ raw = if File.file?(skill_dir)
139
+ File.read(skill_dir)
140
+ else
141
+ skill_path = File.join(skill_dir, 'SKILL.md')
142
+ unless File.exist?(skill_path)
143
+ $stderr.puts "Warning: SKILL.md not found in #{skill_dir}"
144
+ return nil
145
+ end
146
+ File.read(skill_path)
147
+ end
148
+
149
+ skill_body(raw)
150
+ rescue StandardError => e
151
+ $stderr.puts "Warning: Could not load skill '#{skill_id}': #{e.message}"
152
+ nil
153
+ end
154
+
54
155
  def safe_skill_path(path, dir)
55
156
  resolved = File.realpath(path)
56
- resolved.start_with?(File.realpath(dir) + File::SEPARATOR) ? resolved : nil
157
+ root = File.realpath(dir)
158
+ root_with_separator = File.join(root, '')
159
+
160
+ resolved == root || resolved.start_with?(root_with_separator) ? resolved : nil
57
161
  rescue Errno::ENOENT
58
162
  nil
59
163
  end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/spawn_handler.rb
4
+ #
5
+ # Dynamically creates specialist robots using robot_lab's spawn().
6
+ # The primary robot determines what kind of specialist is needed,
7
+ # spawns it on the shared bus, and collects the response.
8
+ # Specialists are cached for reuse within the session.
9
+
10
+ module AIA
11
+ class SpawnHandler
12
+ include ContentExtractor
13
+ include HandlerProtocol
14
+
15
+ MAX_CACHE_SIZE = 5
16
+
17
+ def initialize(robot:, ui_presenter:, tracker:)
18
+ @robot = robot
19
+ @ui_presenter = ui_presenter
20
+ @tracker = tracker
21
+ @spawned = {}
22
+ end
23
+
24
+ attr_writer :robot
25
+
26
+ # Release all cached specialist robots.
27
+ def cleanup!
28
+ @spawned.clear
29
+ end
30
+
31
+ # Spawn a specialist robot to handle a prompt.
32
+ #
33
+ # @param context [HandlerContext] — reads context.prompt, context.specialist_type,
34
+ # and context.spawn_spec (explicit {name:, model:, provider:, system_prompt:})
35
+ # @return [String, nil] specialist's response
36
+ def handle(context)
37
+ prompt = context.prompt
38
+ primary = @robot.chief
39
+ primary.with_bus unless primary.respond_to?(:bus) && primary.bus
40
+
41
+ role, instruction, spawn_opts = resolve_specialist(context, primary, prompt)
42
+
43
+ # Spawn or reuse specialist (evict oldest when cache is full)
44
+ specialist = @spawned[role] ||= begin
45
+ evict_oldest! if @spawned.size >= MAX_CACHE_SIZE
46
+ primary.spawn(name: role, system_prompt: instruction, **spawn_opts)
47
+ end
48
+
49
+ @ui_presenter.display_info("Specialist '#{role}' responding...")
50
+
51
+ result = specialist.run(prompt, mcp: :inherit, tools: :inherit)
52
+ content = extract_content(result)
53
+
54
+ # Track in TrakFlow if available
55
+ if AIA.task_coordinator&.available?
56
+ AIA.task_coordinator.create_task(
57
+ "Specialist: #{prompt[0, 60]}",
58
+ assignee: role,
59
+ labels: %w[specialist spawned],
60
+ creator: primary.name
61
+ )
62
+ end
63
+
64
+ @tracker.record_turn(
65
+ model: AIA.config.models.first.name,
66
+ input: prompt,
67
+ result: result
68
+ )
69
+
70
+ content
71
+ end
72
+
73
+ private
74
+
75
+ # Decide the specialist's role, system prompt, and any model/provider
76
+ # override, from (in priority order): an explicit /spawn spec, an explicit
77
+ # specialist type, or LLM auto-detection.
78
+ #
79
+ # @return [Array(String, String, Hash)] [role, instruction, spawn_opts]
80
+ def resolve_specialist(context, primary, prompt)
81
+ if (spec = context.spawn_spec)
82
+ role = spec[:name]
83
+ instruction = spec[:system_prompt] || "You are #{role}."
84
+ [role, instruction, model_opts(spec)]
85
+ elsif (type = context.specialist_type)
86
+ [type, "You are a #{type} specialist. Answer precisely within your domain of expertise.", {}]
87
+ else
88
+ role, instruction = detect_specialist(primary, prompt)
89
+ [role, instruction, {}]
90
+ end
91
+ end
92
+
93
+ # Model/provider override for an explicit spawn. When a model is given we
94
+ # pass the provider too (even nil) so it overrides the inherited parent
95
+ # provider — e.g. spawning a cloud model from a local-model parent. With no
96
+ # model, the spawned robot inherits its parent's model and provider.
97
+ #
98
+ # @return [Hash]
99
+ def model_opts(spec)
100
+ return {} unless spec[:model]
101
+
102
+ { model: spec[:model], provider: spec[:provider] }
103
+ end
104
+
105
+ def evict_oldest!
106
+ @spawned.delete(@spawned.keys.first)
107
+ end
108
+
109
+ def detect_specialist(primary, prompt)
110
+ @ui_presenter.display_info("Determining specialist type...")
111
+
112
+ result = primary.run(<<~PROMPT, mcp: :none, tools: :none)
113
+ What type of specialist would best answer this question?
114
+ Reply with exactly two lines:
115
+ Line 1: specialist role (e.g., security_expert, data_scientist)
116
+ Line 2: one-sentence instruction for the specialist
117
+
118
+ Question: #{prompt}
119
+ PROMPT
120
+
121
+ reply = extract_content(result)
122
+ lines = reply.strip.split("\n", 2)
123
+ role = lines[0]&.strip&.downcase&.gsub(/\s+/, "_") || "specialist"
124
+ instruction = lines[1]&.strip || "You are a #{role}."
125
+
126
+ [role, instruction]
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'config/model_spec'
4
+
5
+ module AIA
6
+ # Parses the explicit form of the /spawn and /add_recruit directives into a
7
+ # spawn spec:
8
+ #
9
+ # <name> <provider/model> <system prompt...>
10
+ #
11
+ # The second token is parsed as a model via {ModelSpec}, so
12
+ # "ollama/qwen3.6:latest" yields model "qwen3.6:latest" + provider "ollama"
13
+ # (and "lms/..." maps to the openai provider). Use "-" or "inherit" as the
14
+ # model token to keep the parent robot's model and provider.
15
+ #
16
+ # Any token of the form "skill:<id>" (anywhere after the name) is pulled out
17
+ # as a skill assignment and returned in :skills; "skill:a,b" lists several.
18
+ # The skills become the robot's role (its system prompt) when recruited.
19
+ class SpawnSpecParser
20
+ INHERIT_TOKENS = %w[- inherit].freeze
21
+ SKILL_TOKEN = /\Askill:(.+)\z/i
22
+
23
+ # @param args [Array<String>] directive args (two or more expected)
24
+ # @return [Hash] {name:, model:, provider:, system_prompt:, skills:}
25
+ def self.parse(args)
26
+ name = args[0]
27
+ skills, rest = extract_skills(args[1..].to_a)
28
+ model, provider = parse_model(rest[0])
29
+ system_prompt = rest[1..].to_a.join(' ').strip
30
+ system_prompt = nil if system_prompt.empty?
31
+
32
+ { name: name, model: model, provider: provider, system_prompt: system_prompt, skills: skills }
33
+ end
34
+
35
+ # Split "skill:<id>" tokens out of the token list. Returns the collected
36
+ # skill ids and the remaining (model + system-prompt) tokens, order intact.
37
+ #
38
+ # @param tokens [Array<String>]
39
+ # @return [Array(Array<String>, Array<String>)] [skills, remaining]
40
+ def self.extract_skills(tokens)
41
+ skills = []
42
+ remaining = tokens.reject do |token|
43
+ match = token.match(SKILL_TOKEN)
44
+ next false unless match
45
+
46
+ skills.concat(match[1].split(',').map(&:strip).reject(&:empty?))
47
+ true
48
+ end
49
+ [skills, remaining]
50
+ end
51
+
52
+ # Split a "provider/model" token into [model, provider]. Returns [nil, nil]
53
+ # to signal the spawned robot should inherit its parent's model/provider.
54
+ #
55
+ # @param token [String, nil]
56
+ # @return [Array(String, String)]
57
+ def self.parse_model(token)
58
+ return [nil, nil] if token.nil? || INHERIT_TOKENS.include?(token.downcase)
59
+
60
+ spec = ModelSpec.new(name: token)
61
+ provider = spec.provider == 'lms' ? 'openai' : spec.provider
62
+ [spec.name, provider]
63
+ end
64
+ end
65
+ end