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,96 @@
1
+ # lib/aia/patches/ruby_llm_tool_error.rb
2
+ #
3
+ # Two ruby_llm hardening patches:
4
+ #
5
+ # 1. RubyLLM::Chat#execute_tool — rescue any exception raised during tool
6
+ # execution and return it as an error hash instead of raising. Without
7
+ # this, an exception inside a tool skips the add_message call in
8
+ # handle_tool_calls, leaving an orphaned tool_call in the conversation
9
+ # history with no matching tool_result. Most LLMs reject that state on the
10
+ # next request, killing the session. ruby_llm's own execute_tool does NOT
11
+ # rescue tool exceptions, so this is still required as of 1.16.
12
+ #
13
+ # 2. RubyLLM::Provider#complete — normalize OpenAI o-series / gpt-5 params
14
+ # (max_tokens -> max_completion_tokens) before the request.
15
+ #
16
+ # Both patches are applied via #prepend + super so they preserve ruby_llm's own
17
+ # method body (including its instrumentation) and survive internal changes to
18
+ # that body. They are guarded against drift: if a future ruby_llm renames or
19
+ # removes the target method, the patch is skipped with a loud warning rather
20
+ # than silently installing dead code or breaking every request.
21
+
22
+ module AIA
23
+ class OpenAIParameterNormalizer
24
+ class << self
25
+ def normalize(params, model, provider)
26
+ normalized = params.dup
27
+ return normalized unless uses_completion_tokens_param?(model, provider)
28
+ return normalized unless normalized.key?(:max_tokens)
29
+
30
+ max_tokens = normalized.delete(:max_tokens)
31
+ normalized[:max_completion_tokens] ||= max_tokens
32
+ normalized
33
+ end
34
+
35
+ private
36
+
37
+ def uses_completion_tokens_param?(model, provider)
38
+ provider_slug(provider) == 'openai' && model_id(model).match?(/\A(o\d|gpt-5)/i)
39
+ end
40
+
41
+ def provider_slug(provider)
42
+ provider.respond_to?(:slug) ? provider.slug.to_s : nil
43
+ end
44
+
45
+ def model_id(model)
46
+ model.respond_to?(:id) ? model.id.to_s : model.to_s
47
+ end
48
+ end
49
+ end
50
+
51
+ # Prepended onto RubyLLM::Provider. Uses a pass-through (*args, **kwargs)
52
+ # signature so any kwargs ruby_llm adds in future are forwarded to super
53
+ # unchanged; we only rewrite params before delegating.
54
+ module RubyLLMOpenAIParameters
55
+ def complete(*, **kwargs, &)
56
+ kwargs[:params] = AIA::OpenAIParameterNormalizer.normalize(
57
+ kwargs[:params] || {}, kwargs[:model], self
58
+ )
59
+ super
60
+ end
61
+ end
62
+
63
+ # Prepended onto RubyLLM::Chat. Calls super (ruby_llm's own instrumented
64
+ # execute_tool) and only adds exception rescue, so tool-call instrumentation
65
+ # is preserved.
66
+ module RubyLLMChatToolError
67
+ private
68
+
69
+ def execute_tool(tool_call)
70
+ super
71
+ rescue SignalException, SystemExit
72
+ raise
73
+ rescue Exception => e # rubocop:disable Lint/RescueException
74
+ RubyLLM.logger.warn { "Tool #{tool_call.name} raised #{e.class}: #{e.message}" }
75
+ { error: "#{e.class}: #{e.message}" }
76
+ end
77
+ end
78
+ end
79
+
80
+ # --- Apply patches, guarded against ruby_llm API drift ---
81
+
82
+ if RubyLLM::Provider.private_method_defined?(:complete) ||
83
+ RubyLLM::Provider.method_defined?(:complete)
84
+ RubyLLM::Provider.prepend(AIA::RubyLLMOpenAIParameters)
85
+ else
86
+ $stderr.puts "WARNING: [AIA] ruby_llm patch skipped: RubyLLM::Provider#complete " \
87
+ "not found (ruby_llm #{RubyLLM::VERSION}). OpenAI param normalization is off."
88
+ end
89
+
90
+ if RubyLLM::Chat.private_method_defined?(:execute_tool) ||
91
+ RubyLLM::Chat.method_defined?(:execute_tool)
92
+ RubyLLM::Chat.prepend(AIA::RubyLLMChatToolError)
93
+ else
94
+ $stderr.puts "WARNING: [AIA] ruby_llm patch skipped: RubyLLM::Chat#execute_tool " \
95
+ "not found (ruby_llm #{RubyLLM::VERSION}). Tool-exception handling is degraded."
96
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/pipeline_orchestrator.rb
4
+ #
5
+ # Handles per-prompt pipeline processing: building prompt text,
6
+ # executing against the robot, tracking results, and displaying output.
7
+ # Extracted from Session to separate startup concerns from per-turn concerns.
8
+
9
+ require "json"
10
+ require_relative 'skill_utils'
11
+
12
+ module AIA
13
+ class PipelineOrchestrator
14
+ include ContentExtractor
15
+ include SkillUtils
16
+
17
+ def initialize(robot:, prompt_handler:, input_collector:, ui_presenter:, session_tracker:)
18
+ @robot = robot
19
+ @prompt_handler = prompt_handler
20
+ @input_collector = input_collector
21
+ @ui = ui_presenter
22
+ @tracker = session_tracker
23
+ end
24
+
25
+ # Process all prompts in the pipeline.
26
+ #
27
+ # @param config [AIA::Config]
28
+ # :reek:TooManyStatements -- shift-based loop so front-matter pipeline edits take effect; tracking interleaved
29
+ # :reek:DuplicateMethodCall -- config.pipeline is deliberately re-read each iteration because prompt front matter mutates it mid-loop
30
+ def process(config)
31
+ bridge = TrakFlowBridge.new
32
+ tracking = bridge.available? && config.flags.track_pipeline
33
+
34
+ bridge.create_plan_from_pipeline(config.pipeline) if tracking
35
+
36
+ # Use shift-based loop so that prompt front matter which sets
37
+ # config.pipeline (via `next:` or `pipeline:`) is picked up on
38
+ # the next iteration rather than being silently ignored.
39
+ until config.pipeline.empty?
40
+ prompt_id = config.pipeline.shift
41
+ next if prompt_id.nil? || prompt_id.empty?
42
+
43
+ begin
44
+ bridge.update_step_status(prompt_id, :started) if tracking
45
+
46
+ prompt_text = build_prompt_text(prompt_id, config)
47
+ next if prompt_text.nil? || prompt_text.strip.empty?
48
+
49
+ result, content = compute_result(prompt_text, config)
50
+
51
+ bridge.update_step_status(prompt_id, :completed) if tracking
52
+
53
+ @tracker.record_turn(
54
+ model: config.models.first.name,
55
+ input: prompt_text,
56
+ result: result
57
+ )
58
+
59
+ @ui.display_ai_response(content)
60
+ output_to_file(content, config)
61
+ display_metrics(result, config)
62
+ @ui.display_separator
63
+ rescue StandardError => e
64
+ bridge.update_step_status(prompt_id, :failed, reason: e.message) if tracking
65
+ raise
66
+ end
67
+ end
68
+ end
69
+
70
+ # Allow robot to be updated (after model switch)
71
+ attr_writer :robot
72
+
73
+ private
74
+
75
+ # Returns [result, displayable_content] for one prompt — image when in paint
76
+ # mode, otherwise the LLM text response.
77
+ def compute_result(prompt_text, config)
78
+ if AIA.paint?
79
+ image = generate_image(prompt_text, config)
80
+ [image, image]
81
+ else
82
+ result = execute_prompt(prompt_text, config)
83
+ [result, extract_content(result)]
84
+ end
85
+ end
86
+
87
+ # :reek:TooManyStatements -- image request assembly, spinner-wrapped generation, then save/display of the result
88
+ def generate_image(prompt_text, config)
89
+ img_cfg = config.image
90
+ quality = img_cfg.quality
91
+ style = img_cfg.style
92
+ params = {}
93
+ params[:quality] = quality if quality && !quality.to_s.strip.empty?
94
+ params[:style] = style if style && !style.to_s.strip.empty?
95
+
96
+ image = @ui.with_spinner("Generating image") do
97
+ RubyLLM.paint(
98
+ prompt_text,
99
+ model: img_cfg.model || 'dall-e-3',
100
+ size: img_cfg.size || '1024x1024',
101
+ params: params
102
+ )
103
+ end
104
+
105
+ out_file = config.output.file
106
+ if out_file
107
+ ext = '.png'
108
+ path = out_file.chomp(File.extname(out_file)) + ext
109
+ image.save(path)
110
+ "Image saved to: #{path}"
111
+ else
112
+ image.url || "(image returned no URL)"
113
+ end
114
+ end
115
+
116
+ # Execute a prompt, optionally using concurrent MCP
117
+ def execute_prompt(prompt_text, config)
118
+ concurrent_network = maybe_use_concurrent_mcp(prompt_text, config)
119
+
120
+ if concurrent_network
121
+ @ui.with_spinner("Processing (concurrent)") do
122
+ concurrent_network.run(prompt_text)
123
+ end
124
+ else
125
+ @ui.with_spinner("Processing") do
126
+ @robot.run(prompt_text, mcp: :inherit, tools: :inherit)
127
+ end
128
+ end
129
+ end
130
+
131
+ # Check if concurrent MCP mode should be used
132
+ # :reek:TooManyStatements -- ordered eligibility gates (server count, auto/forced, discovery, grouping threshold) before the build
133
+ def maybe_use_concurrent_mcp(prompt_text, config)
134
+ return nil unless (config.mcp_servers || []).size > 1
135
+
136
+ turn_state = AIA.turn_state
137
+ concurrency = config.respond_to?(:concurrency) ? config.concurrency : nil
138
+ return nil unless concurrency&.auto || turn_state.force_concurrent_mcp
139
+
140
+ if turn_state.force_concurrent_mcp
141
+ turn_state.force_concurrent_mcp = false
142
+ end
143
+
144
+ discovery = MCPDiscovery.new
145
+ relevant_servers = discovery.discover(config)
146
+ return nil if relevant_servers.size <= 1
147
+
148
+ grouper = MCPGrouper.new
149
+ groups = grouper.group(relevant_servers)
150
+
151
+ threshold = concurrency.respond_to?(:threshold) ? (concurrency.threshold || 2) : 2
152
+ return nil if groups.size < threshold
153
+
154
+ RobotFactory.build_concurrent_mcp_network(config, groups)
155
+ rescue StandardError => e
156
+ $stderr.puts "Warning: Concurrent MCP setup failed: #{e.message}"
157
+ nil
158
+ end
159
+
160
+ # Build prompt text from a prompt_id
161
+ # :reek:TooManyStatements -- layered prompt assembly: parameters, role prefix, skills, stdin, context files, in a fixed order
162
+ def build_prompt_text(prompt_id, config)
163
+ parsed = @prompt_handler.fetch_prompt(prompt_id)
164
+ return nil unless parsed
165
+
166
+ parameters = parsed.respond_to?(:parameters) ? parsed.parameters : nil
167
+ if parameters && !parameters.empty?
168
+ values = @input_collector.collect(parameters)
169
+ values.each { |k, v| parameters[k] = v }
170
+ end
171
+
172
+ prompt_text = parsed.to_s
173
+
174
+ role = config.prompts.role
175
+ if role && !role.empty?
176
+ role_parsed = @prompt_handler.fetch_role(role)
177
+ if role_parsed
178
+ prompt_text = "#{role_parsed}\n\n#{prompt_text}"
179
+ end
180
+ end
181
+
182
+ # In pipeline mode, append --skill content to each prompt text.
183
+ # In chat mode, skills are injected into the system prompt instead (system_prompt_assembler).
184
+ unless config.flags&.chat == true
185
+ skill_content = load_skills_content(
186
+ Array(config.prompts&.skills),
187
+ skills_base_dir(config)
188
+ )
189
+ prompt_text = "#{prompt_text}\n\n#{skill_content}" if skill_content
190
+ end
191
+
192
+ stdin_content = config.stdin_content
193
+ if stdin_content && !stdin_content.strip.empty?
194
+ prompt_text = "#{prompt_text}\n\n#{stdin_content}"
195
+ config.stdin_content = nil
196
+ end
197
+
198
+ add_context_files(prompt_text, config)
199
+ end
200
+
201
+ # Append context file contents to prompt
202
+ def add_context_files(prompt_text, config)
203
+ context_files = config.context_files
204
+ return prompt_text if context_files.nil? || context_files.empty?
205
+
206
+ context = context_files.map do |file|
207
+ File.read(file) rescue "Error reading file: #{file}"
208
+ end.join("\n\n")
209
+
210
+ return prompt_text if context.strip.empty?
211
+
212
+ "#{prompt_text}\n\n#{context}"
213
+ end
214
+
215
+ # Display token metrics if enabled.
216
+ # Uses result.raw (RubyLLM::Message) for token data, matching ChatLoop's approach.
217
+ def display_metrics(result, config)
218
+ return unless config.flags.tokens
219
+
220
+ if defined?(SimpleFlow::Result) && result.is_a?(SimpleFlow::Result)
221
+ display_network_metrics(result)
222
+ return
223
+ end
224
+
225
+ raw = result.respond_to?(:raw) ? result.raw : nil
226
+ return unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
227
+
228
+ model_id = (raw.respond_to?(:model_id) && raw.model_id) ||
229
+ (raw.respond_to?(:model) && raw.model) ||
230
+ config.models.first.name
231
+ @ui.display_token_metrics(
232
+ model_id: model_id,
233
+ input_tokens: raw.input_tokens,
234
+ output_tokens: raw.output_tokens
235
+ )
236
+ end
237
+
238
+ def display_network_metrics(flow_result)
239
+ metrics_list = []
240
+ flow_result.context.each do |task_name, robot_result|
241
+ next if task_name == :run_params
242
+ next unless robot_result.respond_to?(:raw)
243
+
244
+ raw = robot_result.raw
245
+ next unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
246
+
247
+ model_id = (raw.respond_to?(:model_id) && raw.model_id) ||
248
+ (raw.respond_to?(:model) && raw.model) ||
249
+ task_name.to_s
250
+ display_name = robot_result.respond_to?(:robot_name) ? robot_result.robot_name : task_name.to_s
251
+ metrics_list << {
252
+ model_id: model_id,
253
+ display_name: display_name,
254
+ input_tokens: raw.input_tokens || 0,
255
+ output_tokens: raw.output_tokens || 0,
256
+ elapsed: robot_result.respond_to?(:duration) ? robot_result.duration : nil
257
+ }
258
+ end
259
+ @ui.display_multi_model_metrics(metrics_list) unless metrics_list.empty?
260
+ end
261
+
262
+ # Write content to the output file if configured
263
+ def output_to_file(content, config)
264
+ out_file = config.output.file
265
+ return unless out_file
266
+
267
+ File.open(out_file, "a") do |f|
268
+ f.puts "AI: #{content}"
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIA
4
+ # Loads, reloads, and unloads top-level Ruby plugin files from the configured
5
+ # plugins directory. A plugin is any `*.rb` file directly under
6
+ # `config.paths.plugins_dir`; it may define top-level (Kernel) methods and/or
7
+ # top-level constants. The loader tracks exactly what each file defines so a
8
+ # changed plugin can be cleanly reloaded and a deleted plugin fully removed
9
+ # from the running process.
10
+ class PluginLoader
11
+ class << self
12
+ # Per-process registry of loaded plugins, keyed by basename.
13
+ # Each value: { path:, signature:, defined: { methods: [Symbol], constants: [Symbol] } }
14
+ def registry
15
+ @registry ||= {}
16
+ end
17
+
18
+ def mutex
19
+ @mutex ||= Mutex.new
20
+ end
21
+
22
+ # Load every `*.rb` file at the top level of the plugins directory,
23
+ # starting from a clean slate (any previously-tracked definitions are
24
+ # removed first). Returns the sorted basenames of the loaded plugins.
25
+ #
26
+ # @param config [#paths, #loaded_plugins=]
27
+ # @return [Array<String>]
28
+ def load!(config)
29
+ @config = config
30
+ reset!(config)
31
+
32
+ dir = plugins_dir(config)
33
+ return [] unless dir
34
+
35
+ Dir.glob(File.join(dir, '*.rb')).each { |file| load_file(file, config) }
36
+ loaded_names
37
+ end
38
+
39
+ # Load — or, if already loaded, reload — a single plugin file. On reload,
40
+ # the file's previous definitions are removed before re-executing so that
41
+ # renamed/deleted methods don't linger. Returns the basename, or nil on
42
+ # failure.
43
+ #
44
+ # @param path [String] absolute path to a `*.rb` plugin file
45
+ # @param config [Object, nil] config to keep `loaded_plugins` in sync with
46
+ # @return [String, nil]
47
+ def load_file(path, config = @config)
48
+ mutex.synchronize do
49
+ name = File.basename(path, '.rb')
50
+ remove_definitions(registry[name]) if registry.key?(name)
51
+
52
+ before = current_definitions
53
+ load(path)
54
+ defined = diff_definitions(before, current_definitions)
55
+
56
+ registry[name] = { path: path, signature: signature(path), defined: defined }
57
+ sync_config(config)
58
+ name
59
+ end
60
+ rescue ScriptError, StandardError => e
61
+ warn_failure(path, e)
62
+ nil
63
+ end
64
+
65
+ # Unload a plugin by basename: remove the methods and constants it defined
66
+ # and forget it. Returns the basename if it was loaded, else nil.
67
+ #
68
+ # @param name [String] plugin basename (no extension)
69
+ # @param config [Object, nil]
70
+ # @return [String, nil]
71
+ def unload_file(name, config = @config)
72
+ mutex.synchronize do
73
+ entry = registry.delete(name)
74
+ remove_definitions(entry)
75
+ sync_config(config)
76
+ entry && name
77
+ end
78
+ end
79
+
80
+ # @return [Array<String>] sorted basenames currently loaded
81
+ def loaded_names
82
+ registry.keys.sort
83
+ end
84
+
85
+ # Remove all tracked plugin definitions and clear the registry. Used by
86
+ # load! for a clean reload and by tests for isolation.
87
+ #
88
+ # @param config [Object, nil]
89
+ # @return [void]
90
+ def reset!(config = @config)
91
+ mutex.synchronize do
92
+ registry.each_value { |entry| remove_definitions(entry) }
93
+ registry.clear
94
+ sync_config(config)
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def plugins_dir(config)
101
+ dir = config.paths&.plugins_dir
102
+ return nil if dir.nil? || dir.to_s.strip.empty?
103
+ return nil unless Dir.exist?(dir)
104
+
105
+ dir
106
+ end
107
+
108
+ # Snapshot of the top-level methods and constants currently defined on
109
+ # Object. Top-level `def` creates private instance methods on Object;
110
+ # top-level constants/classes/modules become constants on Object.
111
+ #
112
+ # @return [Hash{Symbol=>Array<Symbol>}]
113
+ def current_definitions
114
+ {
115
+ methods: Object.private_instance_methods(false) + Object.instance_methods(false),
116
+ constants: Object.constants(false)
117
+ }
118
+ end
119
+
120
+ # What appeared between two snapshots.
121
+ def diff_definitions(before, after)
122
+ {
123
+ methods: after[:methods] - before[:methods],
124
+ constants: after[:constants] - before[:constants]
125
+ }
126
+ end
127
+
128
+ # Remove the methods and constants a plugin defined, if still present and
129
+ # owned directly by Object.
130
+ def remove_definitions(entry)
131
+ return unless entry
132
+
133
+ defined = entry[:defined] || {}
134
+ Array(defined[:methods]).each { |m| remove_object_method(m) }
135
+ Array(defined[:constants]).each { |c| remove_object_constant(c) }
136
+ end
137
+
138
+ def remove_object_method(name)
139
+ return unless Object.private_method_defined?(name, false) ||
140
+ Object.method_defined?(name, false)
141
+
142
+ Object.send(:remove_method, name)
143
+ rescue NameError
144
+ # Already gone — nothing to do.
145
+ end
146
+
147
+ def remove_object_constant(name)
148
+ Object.send(:remove_const, name) if Object.const_defined?(name, false)
149
+ rescue NameError
150
+ # Already gone — nothing to do.
151
+ end
152
+
153
+ # A cheap change-signature for a file: [mtime, size]. Nil if unreadable.
154
+ def signature(path)
155
+ stat = File.stat(path)
156
+ [stat.mtime, stat.size]
157
+ rescue SystemCallError
158
+ nil
159
+ end
160
+
161
+ def sync_config(config)
162
+ config.loaded_plugins = loaded_names if config.respond_to?(:loaded_plugins=)
163
+ end
164
+
165
+ def warn_failure(path, error)
166
+ $stderr.puts "Warning: Failed to load plugin '#{path}': #{error.message}"
167
+ end
168
+ end
169
+ end
170
+ end