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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tool_filter/tfidf.rb
4
+ #
5
+ # TF-IDF based tool filtering (Option A).
6
+ # Built once at session start from all tool descriptions (local + MCP).
7
+ # Per-turn: scores user prompt against each tool's description via cosine
8
+ # similarity and returns tool names above threshold, capped at max_tools.
9
+ #
10
+ # Text is normalized before indexing and querying:
11
+ # - Lowercased and tokenized on word boundaries
12
+ # - Each token is Porter-stemmed (via fast-stemmer, a classifier dependency)
13
+ # - Parameter names are appended to each tool's description text
14
+
15
+ require 'classifier'
16
+ require 'fast-stemmer'
17
+ require_relative '../tfidf_math'
18
+
19
+ module AIA
20
+ class ToolFilter
21
+ class TFIDF < ToolFilter
22
+ DEFAULT_THRESHOLD = 0.05
23
+ DEFAULT_MAX_TOOLS = 30
24
+
25
+ # @param tools [Array] tool classes/objects with .name and .description
26
+ # @param fact_asserter [FactAsserter] used for tool_name/tool_description extraction
27
+ # @param threshold [Float] minimum cosine similarity to include a tool (default 0.05)
28
+ # @param max_tools [Integer] maximum tools to return per turn (default 30)
29
+ def initialize(tools:, fact_asserter:, threshold: DEFAULT_THRESHOLD, max_tools: DEFAULT_MAX_TOOLS)
30
+ super(label: "TF-IDF")
31
+ @fact_asserter = fact_asserter
32
+ @threshold = threshold
33
+ @max_tools = max_tools
34
+ @tools = tools
35
+ @tool_entries = []
36
+ @tfidf = nil
37
+ @tool_vectors = []
38
+ end
39
+
40
+ protected
41
+
42
+ def do_prep
43
+ build_index(@tools)
44
+ return if @tool_entries.empty?
45
+
46
+ tool_texts = @tool_entries.map { |e| e[:description] }
47
+ @tfidf = Classifier::TFIDF.new
48
+ @tfidf.fit(tool_texts)
49
+ @tool_vectors = tool_texts.map { |t| @tfidf.transform(t) }
50
+ end
51
+
52
+ def do_filter_with_scores(prompt)
53
+ return [] if @tool_entries.empty? || @tfidf.nil? || prompt.nil? || prompt.strip.empty?
54
+
55
+ query_vector = @tfidf.transform(normalize(prompt))
56
+
57
+ scored = @tool_entries.each_with_index.map do |entry, i|
58
+ score = AIA::TFIDFMath.cosine_similarity(query_vector, @tool_vectors[i])
59
+ { name: entry[:name], score: score }
60
+ end
61
+
62
+ scored
63
+ .select { |e| e[:score] >= @threshold }
64
+ .sort_by { |e| -e[:score] }
65
+ .first(@max_tools)
66
+ rescue StandardError => e
67
+ $stderr.puts "ToolFilter::TFIDF error: #{e.message}"
68
+ []
69
+ end
70
+
71
+ private
72
+
73
+ def build_index(tools)
74
+ Array(tools).each do |tool|
75
+ name = @fact_asserter.tool_name(tool)
76
+ desc = @fact_asserter.tool_description(tool)
77
+ params = extract_param_names(tool)
78
+ next if name.empty?
79
+
80
+ raw_text = [name, desc, params].reject(&:empty?).join(" ")
81
+ expanded = AIA::ToolFilter::WordNetExpander.expand(raw_text)
82
+ @tool_entries << { name: name, description: normalize(expanded) }
83
+ end
84
+
85
+ @tool_count = @tool_entries.size
86
+ end
87
+
88
+ # Collect parameter names from a tool object.
89
+ # RubyLLM::Tool exposes .parameters as {sym => Parameter}.
90
+ # Parameter names are discriminative (e.g. "sql_query", "xpath", "url").
91
+ def extract_param_names(tool)
92
+ return "" unless tool.respond_to?(:parameters)
93
+
94
+ params = tool.parameters
95
+ return "" unless params.respond_to?(:values)
96
+
97
+ names = params.values.map do |p|
98
+ p.respond_to?(:name) ? p.name.to_s : p.to_s
99
+ end
100
+ names.reject(&:empty?).join(" ")
101
+ rescue StandardError
102
+ ""
103
+ end
104
+
105
+ # Normalize text for TF-IDF: tokenize, stem each word, rejoin.
106
+ # Applied symmetrically to both tool text (at index time) and
107
+ # the user prompt (at query time) so the vocabulary matches.
108
+ def normalize(text)
109
+ text.downcase
110
+ .scan(/[a-z]+/)
111
+ .map(&:stem)
112
+ .join(" ")
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/tool_filter/wordnet_expander.rb
4
+ #
5
+ # Expands tool description text with WordNet synonyms at index time.
6
+ # Uses the `wn` CLI from `brew install wordnet`.
7
+ #
8
+ # Expansion is applied once per build_index call (not to user queries).
9
+ # Results are cached in-process so each unique word is only looked up once.
10
+ # If `wn` is not installed, expand() is a no-op that returns the original text.
11
+ #
12
+ # WordNet POS queried: nouns (-synsn) and verbs (-synsv).
13
+ # Multi-word synonyms (containing spaces or underscores) are excluded.
14
+ # Words shorter than MIN_WORD_LENGTH are excluded (filters stop words).
15
+
16
+ require 'shellwords'
17
+
18
+ module AIA
19
+ class ToolFilter
20
+ module WordNetExpander
21
+ MIN_WORD_LENGTH = 4
22
+
23
+ @cache = {}
24
+ @cache_mutex = Mutex.new
25
+ @available = nil
26
+ @available_mutex = Mutex.new
27
+
28
+ class << self
29
+ # Returns true if the `wn` executable is on PATH.
30
+ # Result is cached for the process lifetime.
31
+ def available?
32
+ @available_mutex.synchronize do
33
+ return @available unless @available.nil?
34
+ @available = system("which wn", out: File::NULL, err: File::NULL) ? true : false
35
+ end
36
+ end
37
+
38
+ # Expand text by appending synonyms for each content word.
39
+ # Returns the original text unchanged if wn is unavailable.
40
+ #
41
+ # @param text [String] raw tool description text
42
+ # @return [String] original text plus appended synonym terms
43
+ def expand(text)
44
+ return text unless available?
45
+
46
+ words = text.downcase.scan(/[a-z]{#{MIN_WORD_LENGTH},}/).uniq
47
+ new_terms = words.flat_map { |w| synonyms_for(w) }
48
+ .uniq
49
+ .reject { |w| words.include?(w) }
50
+
51
+ new_terms.empty? ? text : "#{text} #{new_terms.join(' ')}"
52
+ end
53
+
54
+ # Return synonyms for a single word from WordNet (nouns + verbs).
55
+ # Does not include the word itself. Returns [] if not found.
56
+ # Results are cached per-word for the process lifetime.
57
+ #
58
+ # @param word [String] lowercase word to look up
59
+ # @return [Array<String>] synonym strings, single-word only
60
+ def synonyms_for(word)
61
+ # fast path: already cached
62
+ @cache_mutex.synchronize { return @cache[word] if @cache.key?(word) }
63
+
64
+ syns = (query_wn(word, 'n') + query_wn(word, 'v'))
65
+ .uniq
66
+ .reject { |w| w == word }
67
+
68
+ # write path: first writer wins; read-back in same lock so clear_cache!
69
+ # between write and read cannot cause nil to escape
70
+ @cache_mutex.synchronize do
71
+ @cache[word] = syns unless @cache.key?(word)
72
+ @cache[word]
73
+ end
74
+ rescue StandardError
75
+ []
76
+ end
77
+
78
+ # Wipe the in-process synonym cache. Used between tests.
79
+ def clear_cache!
80
+ @cache_mutex.synchronize { @cache.clear }
81
+ end
82
+
83
+ # Reset all cached state. Used in test teardowns.
84
+ def reset_for_testing!
85
+ @available_mutex.synchronize { @available = nil }
86
+ @cache_mutex.synchronize { @cache.clear }
87
+ end
88
+
89
+ private
90
+
91
+ # Shell out to `wn word -syns{pos}` and parse the synset lines.
92
+ #
93
+ # @param word [String] word to look up
94
+ # @param pos [String] part of speech: 'n' (noun) or 'v' (verb)
95
+ # @return [Array<String>] single-word synonyms
96
+ def query_wn(word, pos)
97
+ output = `wn #{Shellwords.escape(word)} -syns#{pos} 2>/dev/null`
98
+ parse_synsets(output)
99
+ rescue StandardError
100
+ []
101
+ end
102
+
103
+ # Parse synset lines from `wn` output.
104
+ #
105
+ # Synset lines start with a lowercase letter (no leading whitespace)
106
+ # and contain comma-separated synonym words. Hypernym/relative lines
107
+ # start with whitespace or contain '=>' and are skipped.
108
+ #
109
+ # @param output [String] raw output from `wn`
110
+ # @return [Array<String>] unique single-word synonym strings
111
+ def parse_synsets(output)
112
+ output.lines.flat_map do |line|
113
+ next [] unless line.match?(/\A[a-z]/)
114
+ next [] if line.include?("=>")
115
+
116
+ line.chomp.split(/,\s*/).map(&:strip).select do |word|
117
+ word.length >= MIN_WORD_LENGTH &&
118
+ !word.include?(' ') &&
119
+ !word.include?('_') &&
120
+ word.match?(/\A[a-z]+\z/)
121
+ end
122
+ end.uniq
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,83 @@
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
+ # :reek:BooleanParameter -- load_db:/save_db: toggle embedding-cache persistence per strategy; mode objects would multiply constructors
17
+ def initialize(label:, db_dir: nil, load_db: false, save_db: false)
18
+ @label = label
19
+ @db_dir = db_dir
20
+ @load_db = load_db
21
+ @save_db = save_db
22
+ @tool_count = 0
23
+ @prep_ms = 0.0
24
+ end
25
+
26
+ # One-time initialization. Captures timing in @prep_ms, returns it.
27
+ def prep
28
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
+ do_prep
30
+ @prep_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
31
+ end
32
+
33
+ # Per-turn query. Returns [names] or nil (nil = all tools).
34
+ def filter(prompt)
35
+ names = filter_with_scores(prompt).map { |e| e[:name] }
36
+ names.empty? ? nil : names
37
+ end
38
+
39
+ # Per-turn query with scores. Returns [{name:, score:}].
40
+ def filter_with_scores(prompt)
41
+ do_filter_with_scores(prompt)
42
+ end
43
+
44
+ # Override in subclasses that need resource cleanup.
45
+ def cleanup; end
46
+
47
+ # True when the filter has indexed at least one tool.
48
+ def available?
49
+ @tool_count.positive?
50
+ end
51
+
52
+ # True when this filter supports database persistence (--load / --save).
53
+ def persistable?
54
+ false
55
+ end
56
+
57
+ protected
58
+
59
+ def do_prep
60
+ raise NotImplementedError, "#{self.class}#do_prep must be implemented"
61
+ end
62
+
63
+ def do_filter_with_scores(_prompt)
64
+ raise NotImplementedError, "#{self.class}#do_filter_with_scores must be implemented"
65
+ end
66
+
67
+ private
68
+
69
+ # Compute a SHA256 fingerprint of the tool set for staleness detection.
70
+ # Sorted alphabetically so insertion order doesn't affect the result.
71
+ #
72
+ # @param tools [Array] tool objects with .name
73
+ # @return [String] 64-char hex digest
74
+ def fingerprint_from_tools(tools)
75
+ require 'digest'
76
+ names = Array(tools).filter_map do |tool|
77
+ n = tool.respond_to?(:name) ? tool.name.to_s.strip : ""
78
+ n.empty? ? nil : n
79
+ end.sort
80
+ Digest::SHA256.hexdigest(names.join(","))
81
+ end
82
+ end
83
+ 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,146 @@
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
+ require 'tty-table'
12
+
13
+ module AIA
14
+ class ToolFilterStrategy
15
+ # Maps filter keys to display info.
16
+ FILTER_META = {
17
+ tfidf: { letter: "A", label: "TF-IDF", score_label: "score" }
18
+ }.freeze
19
+
20
+ DEFAULT_TIMEOUT_S = 10
21
+
22
+ # @param filters [Hash{Symbol => ToolFilter}] e.g. {tfidf: tfidf_filter}
23
+ # @param ui_presenter [UIPresenter, nil] for display (unused currently, reserved)
24
+ # @param timeout_s [Numeric] per-filter wall-clock timeout in seconds
25
+ def initialize(filters: {}, ui_presenter: nil, timeout_s: DEFAULT_TIMEOUT_S)
26
+ @filters = filters
27
+ @ui_presenter = ui_presenter
28
+ @timeout_s = timeout_s
29
+ end
30
+
31
+ # Resolve the tool list for this turn based on the active strategy.
32
+ #
33
+ # @param prompt [String] the user's processed prompt
34
+ # @return [Array<String>, nil] tool names to allow, or nil for all tools
35
+ def resolve(prompt)
36
+ active = available_filters
37
+
38
+ return resolve_single(prompt, active.first) if active.size >= 1
39
+
40
+ # No filters — all tools available
41
+ nil
42
+ end
43
+
44
+ # @return [String] label for the active strategy (used in debug logging)
45
+ def active_strategy_label
46
+ labels = available_filters.map { |key, _| meta_for(key)[:label] }
47
+ labels = ["none"] if labels.empty? # fallback
48
+ labels.first
49
+ end
50
+
51
+ private
52
+
53
+ # Filters that are actually available (have indexed tools).
54
+ def available_filters
55
+ @filters.select { |_key, filter| filter.available? }
56
+ end
57
+
58
+ # Meta info for a filter key, with fallback for unknown keys.
59
+ def meta_for(key)
60
+ FILTER_META[key] || { letter: key.to_s[0].upcase, label: key.to_s, score_label: "score" }
61
+ end
62
+
63
+ # Single filter path: run filter, display results and timing.
64
+ def resolve_single(prompt, (key, filter))
65
+ filter_ms = run_filter_timed(key, filter, prompt)
66
+ scored = filter_ms[:scored]
67
+
68
+ if AIA.debug?
69
+ display_filter_results(key, scored)
70
+ display_timing_table({ key => filter_ms[:ms] })
71
+ end
72
+
73
+ # Return the matched names. An EMPTY array is meaningful: the filter ran
74
+ # and judged no tool relevant to this prompt, so the caller should send NO
75
+ # tools (not the full set). Only a missing filter (see #resolve's no-filter
76
+ # path) or a filter error (the rescue below) yields nil = "use all tools".
77
+ scored.map { |e| e[:name] }
78
+ rescue => e
79
+ AIA.logger.debug "ToolFilterStrategy: #{meta_for(key)[:label]} filter failed: #{e.message}" if AIA.debug?
80
+ nil
81
+ end
82
+
83
+ # Run a filter and return {scored:, ms:}.
84
+ def run_filter_timed(_key, filter, prompt)
85
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ scored = filter.filter_with_scores(prompt)
87
+ ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
88
+ { scored: scored, ms: ms }
89
+ end
90
+
91
+ # Display single-filter results via logger.
92
+ def display_filter_results(key, scored)
93
+ meta = meta_for(key)
94
+ label = meta[:label]
95
+ logger = AIA.logger
96
+
97
+ if scored.empty?
98
+ logger.debug "[#{label}] No tools matched (all tools available)"
99
+ return
100
+ end
101
+
102
+ names = scored.map { |e| e[:name] }.join(', ')
103
+ logger.debug "[#{label}] Tools for this turn (#{scored.size}): #{names}"
104
+
105
+ scored.each do |entry|
106
+ logger.debug "[#{label}] #{entry[:name]} (#{meta[:score_label]}: #{format('%.4f', entry[:score])})"
107
+ end
108
+ end
109
+
110
+ # Print the timing table via logger.
111
+ # :reek:DuplicateMethodCall -- header and each data row map the same columns into different cells
112
+ def display_timing_table(filter_ms_by_key)
113
+ columns = build_timing_columns(filter_ms_by_key)
114
+ return if columns.empty?
115
+
116
+ header = ["Process"] + columns.map { |col| "#{col[:header]}\n#{col[:sub]}" }
117
+ rows = [
118
+ ["prep"] + columns.map { |col| col[:prep] },
119
+ ["filter"] + columns.map { |col| col[:filter] }
120
+ ]
121
+
122
+ alignments = [:left] + Array.new(columns.size, :right)
123
+ rendered = TTY::Table.new(header, rows)
124
+ .render(:unicode, multiline: true, alignments: alignments, padding: [0, 1])
125
+ rendered.each_line { |line| AIA.logger.debug(line.chomp) }
126
+ end
127
+
128
+ # Column data (header, sub-label, prep/filter cells) for each configured filter.
129
+ def build_timing_columns(filter_ms_by_key)
130
+ @filters.map do |key, filter|
131
+ meta = meta_for(key)
132
+ active = filter_ms_by_key.key?(key)
133
+ {
134
+ header: "Option #{meta[:letter]}",
135
+ sub: "(#{meta[:label]})",
136
+ prep: active ? fmt_ms(filter.prep_ms) : "--",
137
+ filter: active ? fmt_ms(filter_ms_by_key[key]) : "--"
138
+ }
139
+ end
140
+ end
141
+
142
+ def fmt_ms(ms)
143
+ "#{ms.round(1)}ms"
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,17 @@
1
+ # lib/aia/tool_introspection.rb
2
+
3
+ module AIA
4
+ # Shared name/description extraction for tool objects, which may be
5
+ # RubyLLM tools, RobotLab tools, or bare tool classes.
6
+ module ToolIntrospection
7
+ module_function
8
+
9
+ def tool_name(tool)
10
+ tool.respond_to?(:name) ? tool.name : tool.class.name
11
+ end
12
+
13
+ def tool_description(tool)
14
+ tool.respond_to?(:description) ? tool.description.to_s : ''
15
+ end
16
+ end
17
+ end