aia 1.1.1 → 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 +259 -50
- 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 +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 +304 -164
- 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 +108 -142
- 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 -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,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/session_tracker.rb
|
|
4
|
+
#
|
|
5
|
+
# Tracks session metrics and outcomes for the learning loop.
|
|
6
|
+
# Records per-turn data: model, tokens, cost, latency, decisions.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
class SessionTracker
|
|
10
|
+
attr_reader :turn_count, :total_cost, :total_tokens, :turns
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@turn_count = 0
|
|
14
|
+
@total_cost = 0.0
|
|
15
|
+
@total_tokens = 0
|
|
16
|
+
@turns = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Record a completed turn.
|
|
20
|
+
#
|
|
21
|
+
# For network results (SimpleFlow::Result), records one entry per
|
|
22
|
+
# robot so the /cost directive can show per-model breakdowns.
|
|
23
|
+
#
|
|
24
|
+
# @param model [String] model used (ignored for network results)
|
|
25
|
+
# @param input [String] user input
|
|
26
|
+
# @param result the LLM response
|
|
27
|
+
# @param decisions [Hash, nil] routing decisions for this turn
|
|
28
|
+
# @param elapsed [Float, nil] seconds the model took to respond
|
|
29
|
+
def record_turn(model:, input:, result:, decisions: nil, elapsed: nil)
|
|
30
|
+
if defined?(SimpleFlow::Result) && result.is_a?(SimpleFlow::Result)
|
|
31
|
+
record_network_turn(input: input, flow_result: result, decisions: decisions, elapsed: elapsed)
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@turn_count += 1
|
|
36
|
+
|
|
37
|
+
metrics = extract_metrics(result)
|
|
38
|
+
@total_cost += metrics[:cost]
|
|
39
|
+
@total_tokens += metrics[:tokens]
|
|
40
|
+
|
|
41
|
+
@turns << {
|
|
42
|
+
model: model,
|
|
43
|
+
input_length: input.to_s.length,
|
|
44
|
+
input_tokens: metrics[:input_tokens],
|
|
45
|
+
output_tokens: metrics[:output_tokens],
|
|
46
|
+
tokens: metrics[:tokens],
|
|
47
|
+
cost: metrics[:cost],
|
|
48
|
+
elapsed: elapsed || 0,
|
|
49
|
+
decisions: decisions&.to_h,
|
|
50
|
+
timestamp: Time.now
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Record a model switch event.
|
|
55
|
+
#
|
|
56
|
+
# @param from [String] previous model
|
|
57
|
+
# @param to [String] new model
|
|
58
|
+
# @param reason [String] reason for the switch
|
|
59
|
+
def record_model_switch(from:, to:, reason: "user_request")
|
|
60
|
+
@turns << {
|
|
61
|
+
type: :model_switch,
|
|
62
|
+
from: from,
|
|
63
|
+
to: to,
|
|
64
|
+
reason: reason,
|
|
65
|
+
timestamp: Time.now
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Record user feedback on the last response.
|
|
70
|
+
#
|
|
71
|
+
# @param satisfied [Boolean] whether the user was satisfied
|
|
72
|
+
def record_user_feedback(satisfied:)
|
|
73
|
+
return if @turns.empty?
|
|
74
|
+
@turns.last[:user_satisfied] = satisfied
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Export session stats as a hash for KBS fact assertion.
|
|
78
|
+
#
|
|
79
|
+
# @return [Hash] session statistics
|
|
80
|
+
def to_facts
|
|
81
|
+
{
|
|
82
|
+
turn_count: @turn_count,
|
|
83
|
+
total_cost: @total_cost,
|
|
84
|
+
total_tokens: @total_tokens
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reset all tracked data.
|
|
89
|
+
def reset!
|
|
90
|
+
@turn_count = 0
|
|
91
|
+
@total_cost = 0.0
|
|
92
|
+
@total_tokens = 0
|
|
93
|
+
@turns.clear
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Expand a network SimpleFlow::Result into one turn entry per robot.
|
|
99
|
+
# Computes TF-IDF similarity of each response against the first.
|
|
100
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
101
|
+
def record_network_turn(input:, flow_result:, decisions: nil, elapsed: nil)
|
|
102
|
+
@turn_count += 1
|
|
103
|
+
now = Time.now
|
|
104
|
+
|
|
105
|
+
# Collect robot data in order for similarity scoring
|
|
106
|
+
robot_entries = []
|
|
107
|
+
response_texts = []
|
|
108
|
+
|
|
109
|
+
# rubocop:disable Metrics/BlockLength
|
|
110
|
+
flow_result.context.each do |task_name, robot_result|
|
|
111
|
+
next if task_name == :run_params
|
|
112
|
+
next unless robot_result.respond_to?(:raw)
|
|
113
|
+
|
|
114
|
+
raw = robot_result.raw
|
|
115
|
+
input_tokens = (raw.respond_to?(:input_tokens) && raw.input_tokens) || 0
|
|
116
|
+
output_tokens = (raw.respond_to?(:output_tokens) && raw.output_tokens) || 0
|
|
117
|
+
tokens = input_tokens + output_tokens
|
|
118
|
+
|
|
119
|
+
model_id = extract_model_id_from_raw(raw)
|
|
120
|
+
model_id ||= robot_result.respond_to?(:robot_name) ? robot_result.robot_name : task_name.to_s
|
|
121
|
+
|
|
122
|
+
cost = tokens.positive? ? compute_cost_for_model(model_id, input_tokens, output_tokens) : 0.0
|
|
123
|
+
robot_elapsed = robot_result.respond_to?(:duration) ? (robot_result.duration || 0) : 0
|
|
124
|
+
|
|
125
|
+
text = if robot_result.respond_to?(:reply)
|
|
126
|
+
robot_result.reply.to_s
|
|
127
|
+
elsif robot_result.respond_to?(:content)
|
|
128
|
+
robot_result.content.to_s
|
|
129
|
+
else
|
|
130
|
+
""
|
|
131
|
+
end
|
|
132
|
+
# rubocop:enable Metrics/BlockLength
|
|
133
|
+
response_texts << text
|
|
134
|
+
|
|
135
|
+
robot_entries << {
|
|
136
|
+
model: model_id,
|
|
137
|
+
input_length: input.to_s.length,
|
|
138
|
+
input_tokens: input_tokens,
|
|
139
|
+
output_tokens: output_tokens,
|
|
140
|
+
tokens: tokens,
|
|
141
|
+
cost: cost,
|
|
142
|
+
elapsed: robot_elapsed,
|
|
143
|
+
decisions: decisions&.to_h,
|
|
144
|
+
timestamp: now
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
148
|
+
|
|
149
|
+
# Compute similarity scores (first model is reference)
|
|
150
|
+
scores = if robot_entries.size > 1
|
|
151
|
+
SimilarityScorer.score(response_texts)
|
|
152
|
+
else
|
|
153
|
+
Array.new(robot_entries.size)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
robot_entries.each_with_index do |entry, i|
|
|
157
|
+
entry[:similarity] = scores[i]
|
|
158
|
+
@total_cost += entry[:cost]
|
|
159
|
+
@total_tokens += entry[:tokens]
|
|
160
|
+
@turns << entry
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def extract_model_id_from_raw(raw)
|
|
165
|
+
return nil unless raw
|
|
166
|
+
return raw.model_id if raw.respond_to?(:model_id) && raw.model_id
|
|
167
|
+
return raw.model if raw.respond_to?(:model) && raw.model
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def compute_cost_for_model(model_id, input_tokens, output_tokens)
|
|
172
|
+
result = CostCalculator.calculate(model_id: model_id, input_tokens: input_tokens, output_tokens: output_tokens)
|
|
173
|
+
result[:available] ? result[:total_cost] : 0.0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def extract_metrics(result)
|
|
177
|
+
input_tokens = 0
|
|
178
|
+
output_tokens = 0
|
|
179
|
+
|
|
180
|
+
# Prefer raw RubyLLM::Message (has token data); fall back to output messages
|
|
181
|
+
source = if result.respond_to?(:raw) && result.raw.respond_to?(:input_tokens)
|
|
182
|
+
result.raw
|
|
183
|
+
elsif result.respond_to?(:output) && result.output.respond_to?(:last)
|
|
184
|
+
result.output.last
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if source.respond_to?(:input_tokens) && source.input_tokens
|
|
188
|
+
input_tokens = source.input_tokens || 0
|
|
189
|
+
output_tokens = source.output_tokens || 0
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
tokens = input_tokens + output_tokens
|
|
193
|
+
cost = tokens.positive? ? compute_cost(result, input_tokens, output_tokens) : 0.0
|
|
194
|
+
|
|
195
|
+
{ input_tokens: input_tokens, output_tokens: output_tokens, tokens: tokens, cost: cost }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Compute cost from token counts and model pricing.
|
|
199
|
+
# Falls back to 0.0 if pricing info is unavailable.
|
|
200
|
+
def compute_cost(result, input_tokens, output_tokens)
|
|
201
|
+
model_id = if result.respond_to?(:robot_name)
|
|
202
|
+
result.robot_name
|
|
203
|
+
elsif result.respond_to?(:model_id)
|
|
204
|
+
result.model_id
|
|
205
|
+
end
|
|
206
|
+
compute_cost_for_model(model_id, input_tokens, output_tokens)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/similarity_scorer.rb
|
|
4
|
+
#
|
|
5
|
+
# Computes TF-IDF cosine similarity between LLM responses.
|
|
6
|
+
# Uses the classifier gem's TF-IDF vectorizer with Porter stemming
|
|
7
|
+
# so that paraphrased responses ("focused" / "focuses") score high.
|
|
8
|
+
|
|
9
|
+
require 'classifier'
|
|
10
|
+
require_relative 'tfidf_math'
|
|
11
|
+
|
|
12
|
+
module AIA
|
|
13
|
+
class SimilarityScorer
|
|
14
|
+
# Compute pairwise similarity of each response against the first.
|
|
15
|
+
#
|
|
16
|
+
# @param responses [Array<String>] ordered response texts (first is reference)
|
|
17
|
+
# @return [Array<Float, nil>] similarity scores (nil for first, 0.0..1.0 for rest)
|
|
18
|
+
def self.score(responses)
|
|
19
|
+
return Array.new(responses.size) if responses.size < 2
|
|
20
|
+
|
|
21
|
+
texts = responses.map { |r| r.to_s.strip }
|
|
22
|
+
return Array.new(responses.size) if texts.first.empty?
|
|
23
|
+
|
|
24
|
+
tfidf = Classifier::TFIDF.new
|
|
25
|
+
tfidf.fit(texts)
|
|
26
|
+
vectors = texts.map { |t| tfidf.transform(t) }
|
|
27
|
+
|
|
28
|
+
vectors.each_with_index.map do |_vec, i|
|
|
29
|
+
if i.zero?
|
|
30
|
+
nil # reference model -- no comparison
|
|
31
|
+
else
|
|
32
|
+
AIA::TFIDFMath.cosine_similarity(vectors[0], vectors[i])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
rescue StandardError
|
|
36
|
+
Array.new(responses.size)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/aia/skill_utils.rb
CHANGED
|
@@ -51,9 +51,113 @@ module AIA
|
|
|
51
51
|
content[(end_marker + 4)..].lstrip
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Resolve the effective skills base directory from config.
|
|
55
|
+
# Skills live in config.skills.dir (set via --skills-dir, the -c config file,
|
|
56
|
+
# or the ~/.prompts/skills default). This is the single resolver used by both
|
|
57
|
+
# skill loading and `--list-skills`, so what is listed is always loadable.
|
|
58
|
+
#
|
|
59
|
+
# @param config [AIA::Config] the AIA configuration
|
|
60
|
+
# @return [String, nil] resolved base directory, or nil if not configured
|
|
61
|
+
def skills_base_dir(config)
|
|
62
|
+
config.skills&.dir
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Render the available skills as a markdown report — the body of --list-skills.
|
|
66
|
+
# Resolves the directory via skills_base_dir (same as loading) and respects a
|
|
67
|
+
# -c config file because it runs after config is built.
|
|
68
|
+
#
|
|
69
|
+
# @param config [AIA::Config]
|
|
70
|
+
# @return [String] markdown listing, or a "no skills" message
|
|
71
|
+
def list_skills_markdown(config)
|
|
72
|
+
dir = skills_base_dir(config)
|
|
73
|
+
|
|
74
|
+
unless dir && Dir.exist?(dir)
|
|
75
|
+
return "No skills directory found at #{dir}\n" \
|
|
76
|
+
"Create this directory and add skill subdirectories to use skills."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
skill_ids = Dir.glob("*/SKILL.md", base: dir).map { |f| File.dirname(f) }.sort
|
|
80
|
+
if skill_ids.empty?
|
|
81
|
+
return "No skills found in #{dir}\n" \
|
|
82
|
+
"Create subdirectories with a SKILL.md file to define skills."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
skill_ids.flat_map { |id| skill_markdown_entry(dir, id) }.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Markdown lines for a single skill entry (heading + front-matter table).
|
|
89
|
+
# Escapes pipe characters so values can't break the table.
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<String>]
|
|
92
|
+
def skill_markdown_entry(dir, skill_id)
|
|
93
|
+
front_matter = parse_front_matter(File.join(dir, skill_id, 'SKILL.md'))
|
|
94
|
+
lines = ["## #{skill_id}", ""]
|
|
95
|
+
|
|
96
|
+
if front_matter.empty?
|
|
97
|
+
lines << "_No front matter found in SKILL.md_"
|
|
98
|
+
else
|
|
99
|
+
lines << "| Key | Value |" << "|-----|-------|"
|
|
100
|
+
front_matter.each do |key, value|
|
|
101
|
+
lines << "| #{key} | #{value.to_s.gsub('|', '\\|')} |"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
lines << ""
|
|
106
|
+
lines
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Load and concatenate content from multiple skills.
|
|
110
|
+
# Used by both pipeline mode (prompt text injection) and chat mode
|
|
111
|
+
# (system prompt injection) to honour the --skill CLI option.
|
|
112
|
+
#
|
|
113
|
+
# @param skill_ids [Array<String>] skill names or path-based IDs
|
|
114
|
+
# @param skills_base_dir [String] base directory for named skills
|
|
115
|
+
# @return [String, nil] joined skill bodies, or nil if none loaded
|
|
116
|
+
def load_skills_content(skill_ids, skills_base_dir)
|
|
117
|
+
ids = Array(skill_ids).reject { |s| s.nil? || s.strip.empty? }
|
|
118
|
+
return nil if ids.empty?
|
|
119
|
+
return nil unless skills_base_dir && Dir.exist?(skills_base_dir)
|
|
120
|
+
|
|
121
|
+
contents = ids.filter_map { |id| load_single_skill_content(id, skills_base_dir) }
|
|
122
|
+
contents.empty? ? nil : contents.join("\n\n")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Load the body of a single skill (front matter stripped).
|
|
126
|
+
# Handles both name-based (looks in skills_base_dir) and path-based IDs.
|
|
127
|
+
#
|
|
128
|
+
# @param skill_id [String] skill name or path
|
|
129
|
+
# @param skills_base_dir [String] base directory for named skills
|
|
130
|
+
# @return [String, nil] skill body text, or nil on error
|
|
131
|
+
def load_single_skill_content(skill_id, skills_base_dir)
|
|
132
|
+
skill_dir = find_skill_dir(skill_id, skills_base_dir)
|
|
133
|
+
unless skill_dir
|
|
134
|
+
$stderr.puts "Warning: Skill '#{skill_id}' not found in #{skills_base_dir}"
|
|
135
|
+
return nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
raw = if File.file?(skill_dir)
|
|
139
|
+
File.read(skill_dir)
|
|
140
|
+
else
|
|
141
|
+
skill_path = File.join(skill_dir, 'SKILL.md')
|
|
142
|
+
unless File.exist?(skill_path)
|
|
143
|
+
$stderr.puts "Warning: SKILL.md not found in #{skill_dir}"
|
|
144
|
+
return nil
|
|
145
|
+
end
|
|
146
|
+
File.read(skill_path)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
skill_body(raw)
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
$stderr.puts "Warning: Could not load skill '#{skill_id}': #{e.message}"
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
54
155
|
def safe_skill_path(path, dir)
|
|
55
156
|
resolved = File.realpath(path)
|
|
56
|
-
|
|
157
|
+
root = File.realpath(dir)
|
|
158
|
+
root_with_separator = File.join(root, '')
|
|
159
|
+
|
|
160
|
+
resolved == root || resolved.start_with?(root_with_separator) ? resolved : nil
|
|
57
161
|
rescue Errno::ENOENT
|
|
58
162
|
nil
|
|
59
163
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/spawn_handler.rb
|
|
4
|
+
#
|
|
5
|
+
# Dynamically creates specialist robots using robot_lab's spawn().
|
|
6
|
+
# The primary robot determines what kind of specialist is needed,
|
|
7
|
+
# spawns it on the shared bus, and collects the response.
|
|
8
|
+
# Specialists are cached for reuse within the session.
|
|
9
|
+
|
|
10
|
+
module AIA
|
|
11
|
+
class SpawnHandler
|
|
12
|
+
include ContentExtractor
|
|
13
|
+
include HandlerProtocol
|
|
14
|
+
|
|
15
|
+
MAX_CACHE_SIZE = 5
|
|
16
|
+
|
|
17
|
+
def initialize(robot:, ui_presenter:, tracker:)
|
|
18
|
+
@robot = robot
|
|
19
|
+
@ui_presenter = ui_presenter
|
|
20
|
+
@tracker = tracker
|
|
21
|
+
@spawned = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_writer :robot
|
|
25
|
+
|
|
26
|
+
# Release all cached specialist robots.
|
|
27
|
+
def cleanup!
|
|
28
|
+
@spawned.clear
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Spawn a specialist robot to handle a prompt.
|
|
32
|
+
#
|
|
33
|
+
# @param context [HandlerContext] — reads context.prompt, context.specialist_type,
|
|
34
|
+
# and context.spawn_spec (explicit {name:, model:, provider:, system_prompt:})
|
|
35
|
+
# @return [String, nil] specialist's response
|
|
36
|
+
def handle(context)
|
|
37
|
+
prompt = context.prompt
|
|
38
|
+
primary = @robot.chief
|
|
39
|
+
primary.with_bus unless primary.respond_to?(:bus) && primary.bus
|
|
40
|
+
|
|
41
|
+
role, instruction, spawn_opts = resolve_specialist(context, primary, prompt)
|
|
42
|
+
|
|
43
|
+
# Spawn or reuse specialist (evict oldest when cache is full)
|
|
44
|
+
specialist = @spawned[role] ||= begin
|
|
45
|
+
evict_oldest! if @spawned.size >= MAX_CACHE_SIZE
|
|
46
|
+
primary.spawn(name: role, system_prompt: instruction, **spawn_opts)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@ui_presenter.display_info("Specialist '#{role}' responding...")
|
|
50
|
+
|
|
51
|
+
result = specialist.run(prompt, mcp: :inherit, tools: :inherit)
|
|
52
|
+
content = extract_content(result)
|
|
53
|
+
|
|
54
|
+
# Track in TrakFlow if available
|
|
55
|
+
if AIA.task_coordinator&.available?
|
|
56
|
+
AIA.task_coordinator.create_task(
|
|
57
|
+
"Specialist: #{prompt[0, 60]}",
|
|
58
|
+
assignee: role,
|
|
59
|
+
labels: %w[specialist spawned],
|
|
60
|
+
creator: primary.name
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@tracker.record_turn(
|
|
65
|
+
model: AIA.config.models.first.name,
|
|
66
|
+
input: prompt,
|
|
67
|
+
result: result
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
content
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Decide the specialist's role, system prompt, and any model/provider
|
|
76
|
+
# override, from (in priority order): an explicit /spawn spec, an explicit
|
|
77
|
+
# specialist type, or LLM auto-detection.
|
|
78
|
+
#
|
|
79
|
+
# @return [Array(String, String, Hash)] [role, instruction, spawn_opts]
|
|
80
|
+
def resolve_specialist(context, primary, prompt)
|
|
81
|
+
if (spec = context.spawn_spec)
|
|
82
|
+
role = spec[:name]
|
|
83
|
+
instruction = spec[:system_prompt] || "You are #{role}."
|
|
84
|
+
[role, instruction, model_opts(spec)]
|
|
85
|
+
elsif (type = context.specialist_type)
|
|
86
|
+
[type, "You are a #{type} specialist. Answer precisely within your domain of expertise.", {}]
|
|
87
|
+
else
|
|
88
|
+
role, instruction = detect_specialist(primary, prompt)
|
|
89
|
+
[role, instruction, {}]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Model/provider override for an explicit spawn. When a model is given we
|
|
94
|
+
# pass the provider too (even nil) so it overrides the inherited parent
|
|
95
|
+
# provider — e.g. spawning a cloud model from a local-model parent. With no
|
|
96
|
+
# model, the spawned robot inherits its parent's model and provider.
|
|
97
|
+
#
|
|
98
|
+
# @return [Hash]
|
|
99
|
+
def model_opts(spec)
|
|
100
|
+
return {} unless spec[:model]
|
|
101
|
+
|
|
102
|
+
{ model: spec[:model], provider: spec[:provider] }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def evict_oldest!
|
|
106
|
+
@spawned.delete(@spawned.keys.first)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def detect_specialist(primary, prompt)
|
|
110
|
+
@ui_presenter.display_info("Determining specialist type...")
|
|
111
|
+
|
|
112
|
+
result = primary.run(<<~PROMPT, mcp: :none, tools: :none)
|
|
113
|
+
What type of specialist would best answer this question?
|
|
114
|
+
Reply with exactly two lines:
|
|
115
|
+
Line 1: specialist role (e.g., security_expert, data_scientist)
|
|
116
|
+
Line 2: one-sentence instruction for the specialist
|
|
117
|
+
|
|
118
|
+
Question: #{prompt}
|
|
119
|
+
PROMPT
|
|
120
|
+
|
|
121
|
+
reply = extract_content(result)
|
|
122
|
+
lines = reply.strip.split("\n", 2)
|
|
123
|
+
role = lines[0]&.strip&.downcase&.gsub(/\s+/, "_") || "specialist"
|
|
124
|
+
instruction = lines[1]&.strip || "You are a #{role}."
|
|
125
|
+
|
|
126
|
+
[role, instruction]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'config/model_spec'
|
|
4
|
+
|
|
5
|
+
module AIA
|
|
6
|
+
# Parses the explicit form of the /spawn and /add_recruit directives into a
|
|
7
|
+
# spawn spec:
|
|
8
|
+
#
|
|
9
|
+
# <name> <provider/model> <system prompt...>
|
|
10
|
+
#
|
|
11
|
+
# The second token is parsed as a model via {ModelSpec}, so
|
|
12
|
+
# "ollama/qwen3.6:latest" yields model "qwen3.6:latest" + provider "ollama"
|
|
13
|
+
# (and "lms/..." maps to the openai provider). Use "-" or "inherit" as the
|
|
14
|
+
# model token to keep the parent robot's model and provider.
|
|
15
|
+
#
|
|
16
|
+
# Any token of the form "skill:<id>" (anywhere after the name) is pulled out
|
|
17
|
+
# as a skill assignment and returned in :skills; "skill:a,b" lists several.
|
|
18
|
+
# The skills become the robot's role (its system prompt) when recruited.
|
|
19
|
+
class SpawnSpecParser
|
|
20
|
+
INHERIT_TOKENS = %w[- inherit].freeze
|
|
21
|
+
SKILL_TOKEN = /\Askill:(.+)\z/i
|
|
22
|
+
|
|
23
|
+
# @param args [Array<String>] directive args (two or more expected)
|
|
24
|
+
# @return [Hash] {name:, model:, provider:, system_prompt:, skills:}
|
|
25
|
+
def self.parse(args)
|
|
26
|
+
name = args[0]
|
|
27
|
+
skills, rest = extract_skills(args[1..].to_a)
|
|
28
|
+
model, provider = parse_model(rest[0])
|
|
29
|
+
system_prompt = rest[1..].to_a.join(' ').strip
|
|
30
|
+
system_prompt = nil if system_prompt.empty?
|
|
31
|
+
|
|
32
|
+
{ name: name, model: model, provider: provider, system_prompt: system_prompt, skills: skills }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Split "skill:<id>" tokens out of the token list. Returns the collected
|
|
36
|
+
# skill ids and the remaining (model + system-prompt) tokens, order intact.
|
|
37
|
+
#
|
|
38
|
+
# @param tokens [Array<String>]
|
|
39
|
+
# @return [Array(Array<String>, Array<String>)] [skills, remaining]
|
|
40
|
+
def self.extract_skills(tokens)
|
|
41
|
+
skills = []
|
|
42
|
+
remaining = tokens.reject do |token|
|
|
43
|
+
match = token.match(SKILL_TOKEN)
|
|
44
|
+
next false unless match
|
|
45
|
+
|
|
46
|
+
skills.concat(match[1].split(',').map(&:strip).reject(&:empty?))
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
[skills, remaining]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Split a "provider/model" token into [model, provider]. Returns [nil, nil]
|
|
53
|
+
# to signal the spawned robot should inherit its parent's model/provider.
|
|
54
|
+
#
|
|
55
|
+
# @param token [String, nil]
|
|
56
|
+
# @return [Array(String, String)]
|
|
57
|
+
def self.parse_model(token)
|
|
58
|
+
return [nil, nil] if token.nil? || INHERIT_TOKENS.include?(token.downcase)
|
|
59
|
+
|
|
60
|
+
spec = ModelSpec.new(name: token)
|
|
61
|
+
provider = spec.provider == 'lms' ? 'openai' : spec.provider
|
|
62
|
+
[spec.name, provider]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|