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.
- checksums.yaml +4 -4
- data/.envrc +5 -1
- data/.loki +231 -0
- data/.quality/flay_baseline.txt +1 -0
- data/.quality/flog_baseline.txt +29 -0
- data/.quality/reek_baseline.txt +80 -0
- data/.rubocop.yml +116 -0
- data/.version +1 -1
- data/CHANGELOG.md +266 -42
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +266 -238
- data/Rakefile +118 -5
- data/architecture_review.md +314 -0
- data/bin/aia +16 -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 +19 -0
- data/examples/context/tech_stack.md +2 -2
- data/examples/prompts_dir/project_summary +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 +306 -159
- data/lib/aia/config/cli_parser.rb +174 -111
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +39 -46
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +121 -138
- data/lib/aia/config.rb +110 -145
- data/lib/aia/content_extractor.rb +153 -0
- data/lib/aia/cost_calculator.rb +38 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +166 -0
- data/lib/aia/delegate_handler.rb +112 -0
- data/lib/aia/directive.rb +33 -18
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +160 -20
- data/lib/aia/directives/context_directives.rb +38 -26
- data/lib/aia/directives/execution_directives.rb +136 -4
- data/lib/aia/directives/model_directives.rb +76 -34
- data/lib/aia/directives/trakflow_directives.rb +44 -0
- data/lib/aia/directives/utility_directives.rb +203 -6
- data/lib/aia/directives/web_and_file_directives.rb +96 -60
- 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 +448 -0
- data/lib/aia/logger.rb +24 -4
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +305 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_utility.rb +57 -0
- data/lib/aia/mention_router.rb +260 -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 +155 -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 +262 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +208 -0
- data/lib/aia/prompt_decomposer.rb +157 -0
- data/lib/aia/prompt_handler.rb +19 -39
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +334 -0
- data/lib/aia/robot_namer.rb +116 -0
- data/lib/aia/session.rb +83 -17
- data/lib/aia/session_tracker.rb +209 -0
- data/lib/aia/similarity_scorer.rb +39 -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 +302 -0
- data/lib/aia/startup_coordinator.rb +150 -0
- data/lib/aia/streaming_runner.rb +169 -0
- data/lib/aia/system_prompt_assembler.rb +88 -0
- data/lib/aia/task_coordinator.rb +202 -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/tool_filter/tfidf.rb +116 -0
- data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
- data/lib/aia/tool_filter.rb +82 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +143 -0
- data/lib/aia/tool_loader.rb +210 -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 +114 -0
- data/lib/aia/trakflow_bridge.rb +173 -0
- data/lib/aia/turn_state.rb +94 -0
- data/lib/aia/ui_presenter.rb +166 -198
- data/lib/aia/utility.rb +134 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
- data/lib/aia/verification_network.rb +58 -0
- data/lib/aia.rb +108 -63
- data/mkdocs.yml +1 -0
- metadata +179 -56
- 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 -164
- 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,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
|