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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tool_filter.rb
4
+ #
5
+ # Abstract base class for tool filtering strategies.
6
+ # Subclasses implement `do_prep` (one-time index build) and
7
+ # `do_filter_with_scores` (per-turn query). The base class provides
8
+ # timing instrumentation and a uniform public API.
9
+
10
+ require_relative 'tool_filter/wordnet_expander'
11
+
12
+ module AIA
13
+ class ToolFilter
14
+ attr_reader :tool_count, :prep_ms, :label
15
+
16
+ def initialize(label:, db_dir: nil, load_db: false, save_db: false)
17
+ @label = label
18
+ @db_dir = db_dir
19
+ @load_db = load_db
20
+ @save_db = save_db
21
+ @tool_count = 0
22
+ @prep_ms = 0.0
23
+ end
24
+
25
+ # One-time initialization. Captures timing in @prep_ms, returns it.
26
+ def prep
27
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ do_prep
29
+ @prep_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
30
+ end
31
+
32
+ # Per-turn query. Returns [names] or nil (nil = all tools).
33
+ def filter(prompt)
34
+ names = filter_with_scores(prompt).map { |e| e[:name] }
35
+ names.empty? ? nil : names
36
+ end
37
+
38
+ # Per-turn query with scores. Returns [{name:, score:}].
39
+ def filter_with_scores(prompt)
40
+ do_filter_with_scores(prompt)
41
+ end
42
+
43
+ # Override in subclasses that need resource cleanup.
44
+ def cleanup; end
45
+
46
+ # True when the filter has indexed at least one tool.
47
+ def available?
48
+ @tool_count.positive?
49
+ end
50
+
51
+ # True when this filter supports database persistence (--load / --save).
52
+ def persistable?
53
+ false
54
+ end
55
+
56
+ protected
57
+
58
+ def do_prep
59
+ raise NotImplementedError, "#{self.class}#do_prep must be implemented"
60
+ end
61
+
62
+ def do_filter_with_scores(_prompt)
63
+ raise NotImplementedError, "#{self.class}#do_filter_with_scores must be implemented"
64
+ end
65
+
66
+ private
67
+
68
+ # Compute a SHA256 fingerprint of the tool set for staleness detection.
69
+ # Sorted alphabetically so insertion order doesn't affect the result.
70
+ #
71
+ # @param tools [Array] tool objects with .name
72
+ # @return [String] 64-char hex digest
73
+ def fingerprint_from_tools(tools)
74
+ require 'digest'
75
+ names = Array(tools).filter_map do |tool|
76
+ n = tool.respond_to?(:name) ? tool.name.to_s.strip : ""
77
+ n.empty? ? nil : n
78
+ end.sort
79
+ Digest::SHA256.hexdigest(names.join(","))
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tool_filter_registry.rb
4
+ #
5
+ # Builds and preps all active ToolFilter instances from the current config.
6
+ # Extracted from Session#start to remove 5 identical if/prep/assign blocks.
7
+
8
+ module AIA
9
+ class ToolFilterRegistry
10
+ # Build and prep all active tool filters according to config flags.
11
+ #
12
+ # @param config [AIA::Config] the current AIA configuration
13
+ # @param tools [Array] all available tool objects
14
+ # @return [Hash{Symbol => ToolFilter}] keyed by filter identifier
15
+ def self.build_from_config(config, tools)
16
+ filters = {}
17
+ fact_asserter = nil
18
+
19
+ if config.flags.auto_tool_filter
20
+ require_relative 'tool_filter/tfidf'
21
+ fact_asserter ||= AIA::FactAsserter.new
22
+ tfidf_filter = ToolFilter::TFIDF.new(tools: tools, fact_asserter: fact_asserter)
23
+ tfidf_filter.prep
24
+ filters[:tfidf] = tfidf_filter
25
+ end
26
+
27
+ filters
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tool_filter_strategy.rb
4
+ #
5
+ # Strategy resolver for TF-IDF tool filtering (Option A).
6
+ # Accepts a Hash of ToolFilter subclass instances keyed by symbol.
7
+ #
8
+ # After each resolve(), prints a timing table showing
9
+ # prep and filter times for the active filter.
10
+
11
+ module AIA
12
+ class ToolFilterStrategy
13
+ # Maps filter keys to display info.
14
+ FILTER_META = {
15
+ tfidf: { letter: "A", label: "TF-IDF", score_label: "score" }
16
+ }.freeze
17
+
18
+ DEFAULT_TIMEOUT_S = 10
19
+
20
+ # @param filters [Hash{Symbol => ToolFilter}] e.g. {tfidf: tfidf_filter}
21
+ # @param ui_presenter [UIPresenter, nil] for display (unused currently, reserved)
22
+ # @param timeout_s [Numeric] per-filter wall-clock timeout in seconds
23
+ def initialize(filters: {}, ui_presenter: nil, timeout_s: DEFAULT_TIMEOUT_S)
24
+ @filters = filters
25
+ @ui_presenter = ui_presenter
26
+ @timeout_s = timeout_s
27
+ end
28
+
29
+ # Resolve the tool list for this turn based on the active strategy.
30
+ #
31
+ # @param prompt [String] the user's processed prompt
32
+ # @return [Array<String>, nil] tool names to allow, or nil for all tools
33
+ def resolve(prompt)
34
+ active = available_filters
35
+
36
+ return resolve_single(prompt, active.first) if active.size >= 1
37
+
38
+ # No filters — all tools available
39
+ nil
40
+ end
41
+
42
+ # @return [String] label for the active strategy (used in debug logging)
43
+ def active_strategy_label
44
+ labels = available_filters.map { |key, _| meta_for(key)[:label] }
45
+ labels = ["none"] if labels.empty? # fallback
46
+ labels.first
47
+ end
48
+
49
+ private
50
+
51
+ # Filters that are actually available (have indexed tools).
52
+ def available_filters
53
+ @filters.select { |_key, filter| filter.available? }
54
+ end
55
+
56
+ # Meta info for a filter key, with fallback for unknown keys.
57
+ def meta_for(key)
58
+ FILTER_META[key] || { letter: key.to_s[0].upcase, label: key.to_s, score_label: "score" }
59
+ end
60
+
61
+ # Single filter path: run filter, display results and timing.
62
+ def resolve_single(prompt, (key, filter))
63
+ filter_ms = run_filter_timed(key, filter, prompt)
64
+ scored = filter_ms[:scored]
65
+
66
+ if AIA.debug?
67
+ display_filter_results(key, scored)
68
+ display_timing_table({ key => filter_ms[:ms] })
69
+ end
70
+
71
+ # Return the matched names. An EMPTY array is meaningful: the filter ran
72
+ # and judged no tool relevant to this prompt, so the caller should send NO
73
+ # tools (not the full set). Only a missing filter (see #resolve's no-filter
74
+ # path) or a filter error (the rescue below) yields nil = "use all tools".
75
+ scored.map { |e| e[:name] }
76
+ rescue => e
77
+ AIA.logger.debug "ToolFilterStrategy: #{meta_for(key)[:label]} filter failed: #{e.message}" if AIA.debug?
78
+ nil
79
+ end
80
+
81
+ # Run a filter and return {scored:, ms:}.
82
+ def run_filter_timed(_key, filter, prompt)
83
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ scored = filter.filter_with_scores(prompt)
85
+ ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
86
+ { scored: scored, ms: ms }
87
+ end
88
+
89
+ # Display single-filter results via logger.
90
+ def display_filter_results(key, scored)
91
+ meta = meta_for(key)
92
+
93
+ if scored.empty?
94
+ AIA.logger.debug "[#{meta[:label]}] No tools matched (all tools available)"
95
+ return
96
+ end
97
+
98
+ names = scored.map { |e| e[:name] }.join(', ')
99
+ AIA.logger.debug "[#{meta[:label]}] Tools for this turn (#{scored.size}): #{names}"
100
+
101
+ scored.each do |entry|
102
+ AIA.logger.debug "[#{meta[:label]}] #{entry[:name]} (#{meta[:score_label]}: #{format('%.4f', entry[:score])})"
103
+ end
104
+ end
105
+
106
+ # Print the timing table via logger.
107
+ # rubocop:disable Metrics/AbcSize
108
+ def display_timing_table(filter_ms_by_key)
109
+ columns = @filters.map do |key, filter|
110
+ meta = meta_for(key)
111
+ active = filter_ms_by_key.key?(key)
112
+ {
113
+ header: "Option #{meta[:letter]}",
114
+ sub: "(#{meta[:label]})",
115
+ prep: active ? fmt_ms(filter.prep_ms) : "--",
116
+ filter: active ? fmt_ms(filter_ms_by_key[key]) : "--"
117
+ }
118
+ end
119
+
120
+ return if columns.empty?
121
+
122
+ pw = 7 # "Process" column width
123
+ widths = columns.map do |col|
124
+ [10, col[:header].length, col[:sub].length, col[:prep].length, col[:filter].length].max
125
+ end
126
+
127
+ lines = []
128
+ lines << ("┌─#{'─' * pw}─" + columns.each_with_index.map { |_, i| "┬─#{'─' * widths[i]}─" }.join + "┐")
129
+ lines << ("│ #{'Process'.ljust(pw)} " + columns.each_with_index.map { |col, i| "│ #{col[:header].ljust(widths[i])} " }.join + "│")
130
+ lines << ("│ #{' ' * pw} " + columns.each_with_index.map { |col, i| "│ #{col[:sub].ljust(widths[i])} " }.join + "│")
131
+ lines << ("├─#{'─' * pw}─" + columns.each_with_index.map { |_, i| "┼─#{'─' * widths[i]}─" }.join + "┤")
132
+ lines << ("│ #{'prep'.ljust(pw)} " + columns.each_with_index.map { |col, i| "│ #{col[:prep].rjust(widths[i])} " }.join + "│")
133
+ lines << ("│ #{'filter'.ljust(pw)} " + columns.each_with_index.map { |col, i| "│ #{col[:filter].rjust(widths[i])} " }.join + "│")
134
+ lines << ("└─#{'─' * pw}─" + columns.each_with_index.map { |_, i| "┴─#{'─' * widths[i]}─" }.join + "┘")
135
+ lines.each { |line| AIA.logger.debug line }
136
+ end
137
+ # rubocop:enable Metrics/AbcSize
138
+
139
+ def fmt_ms(ms)
140
+ "#{ms.round(1)}ms"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tool_loader.rb
4
+ #
5
+ # Tool loading, caching, filtering, and discovery.
6
+ # Extracted from RobotFactory to isolate the tool concern.
7
+ #
8
+ # ToolLoader is a class with instance-level cache state so that two instances
9
+ # have independent caches. Class-level convenience methods delegate to a
10
+ # resettable default instance (reset via ToolLoader.reset_instance! or AIA.reset!).
11
+
12
+ module AIA
13
+ class ToolLoader
14
+ class << self
15
+ # Returns the shared default instance, creating it on first call.
16
+ def instance
17
+ @instance ||= new
18
+ end
19
+
20
+ # Discard the default instance. The next call to .instance creates a fresh one.
21
+ # Called from AIA.reset! for test isolation.
22
+ def reset_instance!
23
+ @instance = nil
24
+ end
25
+
26
+ # Class-level convenience wrappers — delegate to the default instance.
27
+ def cached_tools = instance.cached_tools
28
+ def clear_cache! = instance.clear_cache!
29
+ def load_tools(config) = instance.load_tools(config)
30
+ def filtered_tools(config) = instance.filtered_tools(config)
31
+ def discover_tools = instance.discover_tools
32
+ def eager_load_gem_tools = instance.eager_load_gem_tools
33
+
34
+ def activate_unbundled_gem(name)
35
+ instance.activate_unbundled_gem(name)
36
+ end
37
+ end
38
+
39
+ def initialize
40
+ @tool_cache = nil
41
+ end
42
+
43
+ # Clear the cached tool discovery results.
44
+ # Call when tool paths change via /config directive.
45
+ def clear_cache!
46
+ @tool_cache = nil
47
+ end
48
+
49
+ # Return the current tool cache (nil if not yet populated).
50
+ def cached_tools
51
+ @tool_cache
52
+ end
53
+
54
+ # Load tools from require_libs and tool paths, then cache the result.
55
+ # Subsequent calls to RobotFactory.build skip this entirely if cache exists.
56
+ def load_tools(config)
57
+ Array(config.require_libs).each do |lib|
58
+ require lib
59
+ rescue LoadError
60
+ # Not yet active — activate it (and its deps) via RubyGems, then retry
61
+ if activate_unbundled_gem(lib)
62
+ require lib rescue warn("Warning: Failed to require '#{lib}' after activation")
63
+ else
64
+ $stderr.puts "Warning: Failed to require '#{lib}': gem not found"
65
+ end
66
+ end
67
+
68
+ # Load tool files from paths
69
+ Array(config.tools&.paths).each do |path|
70
+ expanded = File.expand_path(path)
71
+ if File.exist?(expanded)
72
+ require expanded
73
+ else
74
+ $stderr.puts "Warning: Tool file not found: #{path}"
75
+ end
76
+ rescue LoadError, StandardError => e
77
+ $stderr.puts "Warning: Failed to load tool '#{path}': #{e.message}"
78
+ end
79
+
80
+ # Eagerly load tools from gems that use zeitwerk lazy loading
81
+ # (e.g., shared_tools provides .load_all_tools for this purpose)
82
+ eager_load_gem_tools
83
+
84
+ # Re-register PM directives so any AIA::Directive subclasses defined
85
+ # in just-loaded tool files become available in ERB prompt rendering.
86
+ PM::Directive.register_all if defined?(PM::Directive)
87
+
88
+ # Discover loaded tools via ObjectSpace and cache the result
89
+ tools = discover_tools
90
+ @tool_cache = tools
91
+ config.loaded_tools = tools
92
+ config.tool_names = tools.map { |t| t.respond_to?(:name) ? t.name : t.class.name }.join(', ')
93
+ end
94
+
95
+ # Filter tools based on allowed/rejected lists and KBS decisions.
96
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
97
+ def filtered_tools(config)
98
+ tools = config.loaded_tools || []
99
+ allowed = config.tools&.allowed
100
+ rejected = config.tools&.rejected
101
+
102
+ if allowed && !allowed.empty?
103
+ allowed_list = Array(allowed).map(&:strip).map(&:downcase)
104
+ tools = tools.select do |t|
105
+ name = (t.respond_to?(:name) ? t.name : t.class.name).downcase
106
+ # name is a String; Array#intersect? would raise TypeError
107
+ allowed_list.any? { |a| name.include?(a) }
108
+ end
109
+ end
110
+
111
+ if rejected && !rejected.empty?
112
+ rejected_list = Array(rejected).map(&:strip).map(&:downcase)
113
+ tools = tools.reject do |t|
114
+ name = (t.respond_to?(:name) ? t.name : t.class.name).downcase
115
+ # name is a String; Array#intersect? would raise TypeError
116
+ rejected_list.any? { |r| name.include?(r) }
117
+ end
118
+ end
119
+
120
+ seen = {}
121
+ tools.select do |t|
122
+ name = t.respond_to?(:name) ? t.name : t.class.name
123
+ if seen[name]
124
+ false
125
+ else
126
+ seen[name] = true
127
+ true
128
+ end
129
+ end
130
+ end
131
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
132
+
133
+ # Discover RubyLLM::Tool subclasses from ObjectSpace.
134
+ # Skips tools that report themselves as unavailable via #available?.
135
+ def discover_tools
136
+ ObjectSpace.each_object(Class).select do |klass|
137
+ next false unless defined?(RubyLLM::Tool) && klass < RubyLLM::Tool
138
+ begin
139
+ instance = klass.new
140
+ if instance.respond_to?(:available?) && !instance.available?
141
+ tool_name = instance.respond_to?(:name) ? instance.name : klass.name
142
+ $stderr.puts "Info: Tool '#{tool_name}' is not available, skipping"
143
+ next false
144
+ end
145
+ true
146
+ rescue LoadError, StandardError
147
+ false
148
+ end
149
+ end
150
+ end
151
+
152
+ # Activate a gem that isn't yet active by asking RubyGems to activate it.
153
+ # This adds its lib paths to $LOAD_PATH AND activates its dependencies,
154
+ # unlike a manual $LOAD_PATH manipulation which would leave deps unresolved.
155
+ #
156
+ # Under bundler/setup (dev mode), `gem name` raises Gem::LoadError for any
157
+ # gem not in the bundle. The fallback searches known gem install paths directly
158
+ # and adds the lib dir to $LOAD_PATH so Zeitwerk-based gems still load.
159
+ def activate_unbundled_gem(name)
160
+ gem name
161
+ true
162
+ rescue Gem::LoadError
163
+ lib_dir = [Gem.default_dir, Gem.user_dir].compact.flat_map do |d|
164
+ Dir.glob("#{d}/gems/#{name}-*/lib")
165
+ end.max
166
+ return false unless lib_dir
167
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
168
+ true
169
+ rescue StandardError
170
+ false
171
+ end
172
+
173
+ # Eagerly load tool classes from any required gem that uses lazy loading
174
+ # (e.g. Zeitwerk). Calls .load_all_tools on every module that responds to it,
175
+ # so any gem following that convention works — not just SharedTools.
176
+ #
177
+ # Gem::LoadError (a LoadError, not a StandardError) can fire when a tool's
178
+ # dependency has a version conflict with an already-activated gem. The inner
179
+ # rescue handles that per-module; the fallback loads constants one-at-a-time
180
+ # so tools without conflicting deps still become available.
181
+ def eager_load_gem_tools
182
+ ObjectSpace.each_object(Module) do |mod|
183
+ if mod.respond_to?(:load_all_tools)
184
+ begin
185
+ mod.load_all_tools
186
+ rescue LoadError, StandardError
187
+ eager_load_namespace_fallback(mod)
188
+ end
189
+ end
190
+ rescue LoadError, StandardError
191
+ next
192
+ end
193
+ rescue LoadError, StandardError => e
194
+ $stderr.puts "Warning: Failed to eager-load gem tools: #{e.message}"
195
+ end
196
+
197
+ # Fallback when bulk load_all_tools fails: walk the module's namespace
198
+ # recursively (up to 3 levels) and trigger each constant's autoload
199
+ # individually, skipping any that raise a load error.
200
+ def eager_load_namespace_fallback(mod, depth = 0)
201
+ return if depth > 3
202
+ mod.constants.each do |const_name|
203
+ child = mod.const_get(const_name)
204
+ eager_load_namespace_fallback(child, depth + 1) if child.is_a?(Module)
205
+ rescue LoadError, StandardError
206
+ next
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tool_utility.rb
4
+ #
5
+ # Pure tool state-query methods mixed into AIA::Utility via `class << self include`.
6
+ # No display or formatting concerns — those live in Utility directly.
7
+
8
+ module AIA
9
+ module ToolUtility
10
+ def tools?
11
+ return true if AIA.config&.tool_names && !AIA.config.tool_names.empty?
12
+ total_tool_count.positive?
13
+ end
14
+
15
+ def total_tool_count
16
+ local = Array(AIA.config&.loaded_tools).size
17
+ return local if AIA.config&.flags&.no_mcp
18
+ mcp = defined?(RubyLLM::MCP) ? RubyLLM::MCP.clients.sum { |_, c| c.tools.count } : 0
19
+ local + mcp
20
+ end
21
+
22
+ def user_tools?
23
+ AIA.config&.tools&.paths&.any?
24
+ end
25
+
26
+ def supports_tools?
27
+ AIA.client&.model&.supports_functions? || false
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tools/delegate_to_foreman_tool.rb
4
+ #
5
+ # Lightweight tool given to Tobor (and other primary robots) that
6
+ # delegates task board operations to Foreman, a specialist robot
7
+ # with full TaskBoardTool access. Foreman is built on first use
8
+ # and cached for the session.
9
+
10
+ module AIA
11
+ class DelegateToForemanTool < RubyLLM::Tool
12
+ description "Delegate a task management request to Foreman, the task board manager. " \
13
+ "Use this when you need to create, assign, track, or manage tasks. " \
14
+ "Pass a natural language description of what you need done on the task board."
15
+
16
+ param :request, type: :string, required: true,
17
+ desc: "Natural language description of the task board operation " \
18
+ "(e.g., 'create a research task about recessions assigned to Tobor with labels research, economics')"
19
+
20
+ def available?
21
+ AIA.task_coordinator&.available? || false
22
+ end
23
+
24
+ def execute(request:, **_params)
25
+ foreman = self.class.foreman
26
+ result = foreman.run(request)
27
+ result.respond_to?(:reply) ? result.reply : result.to_s
28
+ rescue StandardError => e
29
+ "Foreman error: #{e.message}"
30
+ end
31
+
32
+ class << self
33
+ def foreman
34
+ @foreman ||= build_foreman
35
+ end
36
+
37
+ def reset!
38
+ @foreman = nil
39
+ end
40
+
41
+ private
42
+
43
+ def build_foreman
44
+ model_spec = AIA.config.models.first
45
+ run_config = RobotFactory.build_run_config(AIA.config)
46
+
47
+ RobotFactory.build_robot(
48
+ model_spec,
49
+ name: 'Foreman',
50
+ system_prompt: foreman_system_prompt,
51
+ local_tools: [TaskBoardTool],
52
+ config: run_config
53
+ )
54
+ end
55
+
56
+ def foreman_system_prompt
57
+ <<~PROMPT
58
+ You are Foreman, the task board manager. You manage a shared TrakFlow
59
+ task board where robots can create, claim, and complete tasks.
60
+
61
+ When given a request, use the task_board tool to execute it. Be precise
62
+ and efficient — execute the operation and report the result concisely.
63
+
64
+ You can: create tasks, plan multi-step work, check ready tasks, claim
65
+ tasks, mark tasks complete, block tasks, and report board status.
66
+ PROMPT
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tools/recruit_robot_tool.rb
4
+ #
5
+ # RubyLLM::Tool that lets the crew chief grow its own crew. The chief can
6
+ # name a new robot, pick its provider/model (or inherit the chief's), and
7
+ # write its system prompt — all through standard tool-use protocol. The
8
+ # newcomer joins the running crew and answers to @name immediately.
9
+
10
+ module AIA
11
+ class RecruitRobotTool < RubyLLM::Tool
12
+ description "Recruit a new robot into the crew. Give it a name, an " \
13
+ "optional provider/model (omit to inherit yours), optional " \
14
+ "skills that define its role, and/or a system prompt. The " \
15
+ "newcomer joins the crew right away and can be addressed with @name."
16
+
17
+ param :name, type: :string, required: true,
18
+ desc: "Unique name for the new robot, e.g. 'researcher'. Reserved: 'crew'."
19
+ param :model, type: :string,
20
+ desc: "Optional provider/model, e.g. 'ollama/qwen3.6:latest' or 'gpt-4o'. " \
21
+ "Omit (or pass '-') to inherit the chief's model and provider."
22
+ param :skills, type: :string,
23
+ desc: "Optional comma-separated skill ids to assign as the robot's role " \
24
+ "(e.g. 'security-review,ruby-style'). Each skill's content becomes " \
25
+ "part of its system prompt. Use this to give crew members distinct jobs."
26
+ param :system_prompt, type: :string,
27
+ desc: "Optional extra system prompt, appended after any skills — its role, " \
28
+ "persona, and instructions."
29
+
30
+ def execute(name:, model: nil, skills: nil, system_prompt: nil)
31
+ robot = AIA::Crew.recruit(build_spec(name, model, skills, system_prompt))
32
+ "Recruited '#{robot.name}' into the crew on #{robot.model || 'the chief\'s model'}. " \
33
+ "Address it with @#{robot.name}."
34
+ rescue AIA::CrewError => e
35
+ "Could not recruit '#{name}': #{e.message}"
36
+ end
37
+
38
+ private
39
+
40
+ # Translate the tool's flat params into a Crew spec, splitting a
41
+ # "provider/model" string into its parts (nil model => inherit the chief's)
42
+ # and a comma-separated skills string into a list.
43
+ def build_spec(name, model, skills, system_prompt)
44
+ parsed_model, provider = SpawnSpecParser.parse_model(model)
45
+ prompt = system_prompt.to_s.strip
46
+ {
47
+ name: name,
48
+ model: parsed_model,
49
+ provider: provider,
50
+ skills: split_skills(skills),
51
+ system_prompt: prompt.empty? ? nil : prompt
52
+ }
53
+ end
54
+
55
+ # "security, ruby-style" => ["security", "ruby-style"]
56
+ def split_skills(skills)
57
+ skills.to_s.split(',').map(&:strip).reject(&:empty?)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tools/reskill_robot_tool.rb
4
+ #
5
+ # RubyLLM::Tool that lets the crew chief reset one of its crew members to a
6
+ # clean slate and give it a different role. The member keeps its name and model
7
+ # but gets a fresh conversation and a new skill/system prompt — useful when a
8
+ # recruit drifted off task or needs to be repurposed for the next phase.
9
+
10
+ module AIA
11
+ class ReskillRobotTool < RubyLLM::Tool
12
+ description "Reset a crew member to a clean slate and re-skill it. The robot " \
13
+ "keeps its @name and model but gets a fresh conversation and a new " \
14
+ "role from the given skills and/or system prompt. Use this to " \
15
+ "repurpose a member or recover one that went off task."
16
+
17
+ param :name, type: :string, required: true,
18
+ desc: "Name of the existing crew member to reset (cannot be the chief)."
19
+ param :skills, type: :string,
20
+ desc: "Optional comma-separated skill ids to assign as the new role " \
21
+ "(e.g. 'testing,performance')."
22
+ param :system_prompt, type: :string,
23
+ desc: "Optional extra system prompt, appended after any skills."
24
+
25
+ def execute(name:, skills: nil, system_prompt: nil)
26
+ prompt = system_prompt.to_s.strip
27
+ robot = AIA::Crew.reskill(
28
+ name,
29
+ skills: split_skills(skills),
30
+ system_prompt: prompt.empty? ? nil : prompt
31
+ )
32
+ "Reset '#{robot.name}' to a clean slate with its new role. Address it with @#{robot.name}."
33
+ rescue AIA::CrewError => e
34
+ "Could not reskill '#{name}': #{e.message}"
35
+ end
36
+
37
+ private
38
+
39
+ # "testing, performance" => ["testing", "performance"]
40
+ def split_skills(skills)
41
+ skills.to_s.split(',').map(&:strip).reject(&:empty?)
42
+ end
43
+ end
44
+ end