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.
- checksums.yaml +4 -4
- data/.envrc +9 -1
- data/.loki +11 -0
- data/.reek.yml +160 -0
- data/.rubocop.yml +116 -0
- data/.rubocop_strict.yml +15 -0
- data/.version +1 -1
- data/CHANGELOG.md +269 -55
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +267 -239
- data/Rakefile +5 -5
- data/_typos.toml +10 -0
- data/aia.gemspec +92 -0
- data/architecture_review.md +314 -0
- data/bin/aia +16 -0
- data/config/aia.yml +13 -0
- data/docs/AGENTS.md +40 -0
- data/docs/advanced-prompting.md +67 -3
- data/docs/cli-reference.md +312 -56
- data/docs/configuration.md +130 -19
- data/docs/contributing.md +56 -2
- data/docs/directives-reference.md +593 -78
- data/docs/faq.md +85 -3
- data/docs/guides/available-models.md +1 -1
- data/docs/guides/basic-usage.md +6 -6
- data/docs/guides/chat.md +40 -16
- data/docs/guides/crew.md +239 -0
- data/docs/guides/executable-prompts.md +1 -1
- data/docs/guides/index.md +1 -0
- data/docs/guides/models.md +15 -0
- data/docs/index.md +29 -2
- data/docs/installation.md +44 -17
- data/docs/mcp-integration.md +40 -0
- data/docs/prompt_management.md +85 -86
- data/docs/security.md +47 -0
- data/docs/special_projects_guide.md +386 -0
- data/docs/tools-and-mcp-examples.md +23 -0
- data/docs/workflows-and-pipelines.md +84 -7
- data/examples/.gitignore +1 -0
- data/examples/00_setup_aia.sh +27 -44
- data/examples/11_multi_model.sh +4 -14
- data/examples/12_token_usage.sh +3 -12
- data/examples/18_tools.sh +10 -2
- data/examples/22_chat_mode.sh +0 -10
- data/examples/23_verify.sh +139 -0
- data/examples/24_decompose.sh +139 -0
- data/examples/25_spawn.sh +139 -0
- data/examples/26_debate.sh +97 -0
- data/examples/27_mention_routing.sh +157 -0
- data/examples/28_model_switching.sh +106 -0
- data/examples/29_agent_harness.sh +177 -0
- data/examples/README.md +65 -0
- data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
- data/examples/aia_config.yml +1 -1
- data/examples/aia_config_orchestrator.yml +45 -0
- data/examples/common.sh +18 -6
- data/examples/context/tech_stack.md +2 -2
- data/examples/prompts_dir/roles/orchestrator.md +21 -0
- data/examples/requirements/sinatra_taskflow_app.md +139 -0
- data/examples/rules/01_classify_ruby.rb +16 -0
- data/examples/rules/02_prefer_claude_for_code.rb +19 -0
- data/examples/rules/03_gate_prompt_length.rb +19 -0
- data/examples/rules/04_tool_selection.rb +41 -0
- data/examples/rules/README.md +30 -0
- data/examples/run_all.sh +48 -15
- data/examples/tools/word_count_tool.rb +1 -1
- data/lib/AGENTS.md +57 -0
- data/lib/aia/chat_loop.rb +263 -167
- data/lib/aia/config/cli_parser.rb +217 -145
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +52 -51
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +171 -216
- data/lib/aia/config.rb +111 -145
- data/lib/aia/content_extractor.rb +155 -0
- data/lib/aia/cost_calculator.rb +39 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +174 -0
- data/lib/aia/delegate_handler.rb +116 -0
- data/lib/aia/directive.rb +43 -26
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +214 -60
- data/lib/aia/directives/context_directives.rb +67 -52
- data/lib/aia/directives/execution_directives.rb +141 -4
- data/lib/aia/directives/model_directives.rb +163 -141
- data/lib/aia/directives/trakflow_directives.rb +62 -0
- data/lib/aia/directives/utility_directives.rb +227 -30
- data/lib/aia/directives/web_and_file_directives.rb +126 -77
- data/lib/aia/errors.rb +15 -0
- data/lib/aia/fact_asserter.rb +27 -0
- data/lib/aia/fzf.rb +9 -31
- data/lib/aia/handler_context.rb +17 -0
- data/lib/aia/handler_protocol.rb +19 -0
- data/lib/aia/history_transfer.rb +55 -0
- data/lib/aia/input_collector.rb +3 -3
- data/lib/aia/layered_orchestrator.rb +471 -0
- data/lib/aia/logger.rb +45 -25
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +315 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_server_config.rb +30 -0
- data/lib/aia/mcp_utility.rb +60 -0
- data/lib/aia/mention_router.rb +217 -0
- data/lib/aia/model_alias_registry.rb +97 -0
- data/lib/aia/model_switch_handler.rb +100 -0
- data/lib/aia/network_builder.rb +160 -0
- data/lib/aia/network_memory_manager.rb +55 -0
- data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
- data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
- data/lib/aia/pipeline_orchestrator.rb +272 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +211 -0
- data/lib/aia/prompt_decomposer.rb +159 -0
- data/lib/aia/prompt_handler.rb +56 -82
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +338 -0
- data/lib/aia/robot_namer.rb +110 -0
- data/lib/aia/session.rb +87 -17
- data/lib/aia/session_tracker.rb +207 -0
- data/lib/aia/similarity_scorer.rb +41 -0
- data/lib/aia/skill_utils.rb +105 -1
- data/lib/aia/spawn_handler.rb +129 -0
- data/lib/aia/spawn_spec_parser.rb +65 -0
- data/lib/aia/special_mode_handler.rb +322 -0
- data/lib/aia/speech.rb +67 -0
- data/lib/aia/startup_coordinator.rb +151 -0
- data/lib/aia/streaming_runner.rb +172 -0
- data/lib/aia/system_prompt_assembler.rb +92 -0
- data/lib/aia/task_coordinator.rb +207 -0
- data/lib/aia/task_decomposer.rb +57 -0
- data/lib/aia/task_executor.rb +51 -0
- data/lib/aia/tfidf_math.rb +27 -0
- data/lib/aia/timing.rb +15 -0
- data/lib/aia/tool_filter/tfidf.rb +116 -0
- data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
- data/lib/aia/tool_filter.rb +83 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +146 -0
- data/lib/aia/tool_introspection.rb +17 -0
- data/lib/aia/tool_loader.rb +216 -0
- data/lib/aia/tool_utility.rb +30 -0
- data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
- data/lib/aia/tools/recruit_robot_tool.rb +60 -0
- data/lib/aia/tools/reskill_robot_tool.rb +44 -0
- data/lib/aia/tools/task_board_tool.rb +115 -0
- data/lib/aia/trakflow_bridge.rb +175 -0
- data/lib/aia/turn_state.rb +95 -0
- data/lib/aia/ui_presenter.rb +182 -206
- data/lib/aia/utility.rb +136 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
- data/lib/aia/verification_network.rb +57 -0
- data/lib/aia.rb +124 -63
- data/mkdocs.yml +1 -0
- metadata +187 -58
- data/justfile +0 -215
- data/lib/aia/adapter/chat_execution.rb +0 -242
- data/lib/aia/adapter/error_handler.rb +0 -68
- data/lib/aia/adapter/gem_activator.rb +0 -57
- data/lib/aia/adapter/mcp_connector.rb +0 -274
- data/lib/aia/adapter/modality_handlers.rb +0 -167
- data/lib/aia/adapter/model_registry.rb +0 -81
- data/lib/aia/adapter/multi_model_chat.rb +0 -218
- data/lib/aia/adapter/provider_configurator.rb +0 -59
- data/lib/aia/adapter/tool_filter.rb +0 -85
- data/lib/aia/adapter/tool_loader.rb +0 -90
- data/lib/aia/chat_processor_service.rb +0 -178
- data/lib/aia/prompt_pipeline.rb +0 -183
- data/lib/aia/ruby_llm_adapter.rb +0 -95
- data/lib/extensions/openstruct_merge.rb +0 -48
- data/lib/extensions/ruby_llm/.irbrc +0 -56
- data/lib/extensions/ruby_llm/modalities.rb +0 -36
- data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
- data/lib/refinements/string.rb +0 -16
- 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
|