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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/system_prompt_assembler.rb
|
|
4
|
+
#
|
|
5
|
+
# System prompt resolution, identity prompts, and role loading.
|
|
6
|
+
# Extracted from RobotFactory to isolate the prompt assembly concern.
|
|
7
|
+
# Completely stateless — no module ivars.
|
|
8
|
+
|
|
9
|
+
require_relative 'skill_utils'
|
|
10
|
+
|
|
11
|
+
module AIA
|
|
12
|
+
module SystemPromptAssembler
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Resolve system prompt from config (including role).
|
|
16
|
+
#
|
|
17
|
+
# @param config [AIA::Config] the AIA configuration
|
|
18
|
+
# @param model_spec [ModelSpec, nil] optional model spec with role override
|
|
19
|
+
# @return [String, nil] the assembled system prompt
|
|
20
|
+
def resolve_system_prompt(config, model_spec = nil)
|
|
21
|
+
system_prompt = config.prompts.system_prompt
|
|
22
|
+
|
|
23
|
+
role_id = model_spec&.role || config.prompts.role
|
|
24
|
+
if role_id && !role_id.empty?
|
|
25
|
+
role_content = load_role_content(config, role_id)
|
|
26
|
+
if role_content
|
|
27
|
+
system_prompt = [system_prompt, role_content].compact.join("\n\n")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# In chat mode inject --skill content here so it persists across all turns.
|
|
32
|
+
# Pipeline mode appends skills to each prompt text instead (pipeline_orchestrator).
|
|
33
|
+
if config.flags&.chat == true
|
|
34
|
+
skill_content = AIA::SkillUtils.load_skills_content(
|
|
35
|
+
Array(config.prompts&.skills),
|
|
36
|
+
AIA::SkillUtils.skills_base_dir(config)
|
|
37
|
+
)
|
|
38
|
+
system_prompt = [system_prompt, skill_content].compact.join("\n\n") if skill_content
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
system_prompt
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Build a system prompt fragment that tells a robot its name, its
|
|
45
|
+
# model, and the other robots in the network.
|
|
46
|
+
#
|
|
47
|
+
# @param robot_name [String] this robot's creative name
|
|
48
|
+
# @param spec [ModelSpec] this robot's model spec
|
|
49
|
+
# @param roster [Array<Hash>] all robots: [{ name:, spec: }, ...]
|
|
50
|
+
# @return [String]
|
|
51
|
+
def build_identity_prompt(robot_name, spec, roster)
|
|
52
|
+
provider_label = spec.provider ? " (#{spec.provider})" : ""
|
|
53
|
+
lines = ["You are #{robot_name}, powered by #{spec.name}#{provider_label}."]
|
|
54
|
+
|
|
55
|
+
if roster.size > 1
|
|
56
|
+
lines << "You are part of a team of AI robots:"
|
|
57
|
+
roster.each do |entry|
|
|
58
|
+
p = entry[:spec].provider ? " (#{entry[:spec].provider})" : ""
|
|
59
|
+
marker = entry[:name] == robot_name ? " ← you" : ""
|
|
60
|
+
lines << " - #{entry[:name]}: #{entry[:spec].name}#{p}#{marker}"
|
|
61
|
+
end
|
|
62
|
+
lines << "Users can address a specific robot with @name mentions."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
lines.join("\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Load role file content.
|
|
69
|
+
#
|
|
70
|
+
# @param config [AIA::Config] the AIA configuration
|
|
71
|
+
# @param role_id [String] the role identifier
|
|
72
|
+
# @return [String, nil] the role file content or nil
|
|
73
|
+
def load_role_content(config, role_id)
|
|
74
|
+
roles_prefix = config.prompts.roles_prefix
|
|
75
|
+
unless role_id.start_with?(roles_prefix)
|
|
76
|
+
role_id = "#{roles_prefix}/#{role_id}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
role_file = File.join(config.prompts.dir, "#{role_id}#{config.prompts.extname}")
|
|
80
|
+
return nil unless File.exist?(role_file)
|
|
81
|
+
|
|
82
|
+
File.read(role_file)
|
|
83
|
+
rescue => e
|
|
84
|
+
$stderr.puts "Warning: Could not load role '#{role_id}': #{e.message}"
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/task_coordinator.rb
|
|
4
|
+
#
|
|
5
|
+
# Bridges robots and TrakFlow task management.
|
|
6
|
+
# Robots use this to create tasks for each other, claim work,
|
|
7
|
+
# and report completion. Wraps TrakFlow's database API in a
|
|
8
|
+
# robot-friendly interface.
|
|
9
|
+
|
|
10
|
+
module AIA
|
|
11
|
+
class TaskCoordinator
|
|
12
|
+
def initialize(bridge: TrakFlowBridge.new)
|
|
13
|
+
@bridge = bridge
|
|
14
|
+
@db = bridge.db
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def available?
|
|
18
|
+
@bridge.available?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Clear all tasks from the board for a fresh session.
|
|
22
|
+
def clear!
|
|
23
|
+
return unless available?
|
|
24
|
+
@db.clear!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A robot creates a task for another robot (or any robot) to handle.
|
|
28
|
+
#
|
|
29
|
+
# @param title [String] task description
|
|
30
|
+
# @param assignee [String, nil] target robot name, nil = any robot
|
|
31
|
+
# @param parent_id [String, nil] parent task for hierarchy
|
|
32
|
+
# @param blocked_by [Array<String>] task IDs that must complete first
|
|
33
|
+
# @param labels [Array<String>] labels like "robot:alice", "domain:code"
|
|
34
|
+
# @param creator [String] name of the creating robot
|
|
35
|
+
# @return [TrakFlow::Models::Task, nil]
|
|
36
|
+
def create_task(title, assignee: nil, parent_id: nil,
|
|
37
|
+
blocked_by: [], labels: [], creator: "aia")
|
|
38
|
+
return nil unless available?
|
|
39
|
+
|
|
40
|
+
task = TrakFlow::Models::Task.new(
|
|
41
|
+
title: title, assignee: assignee, type: "task"
|
|
42
|
+
)
|
|
43
|
+
task = @db.create_task(task)
|
|
44
|
+
|
|
45
|
+
@db.add_label(TrakFlow::Models::Label.new(
|
|
46
|
+
task_id: task.id, name: "creator:#{creator}"
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
labels.each do |label|
|
|
50
|
+
@db.add_label(TrakFlow::Models::Label.new(
|
|
51
|
+
task_id: task.id, name: label
|
|
52
|
+
))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
blocked_by.each do |blocker_id|
|
|
56
|
+
@db.add_dependency(TrakFlow::Models::Dependency.new(
|
|
57
|
+
source_id: blocker_id, target_id: task.id, type: "blocks"
|
|
58
|
+
))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if parent_id
|
|
62
|
+
@db.add_dependency(TrakFlow::Models::Dependency.new(
|
|
63
|
+
source_id: parent_id, target_id: task.id, type: "parent-child"
|
|
64
|
+
))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
task
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A robot creates a full plan (blueprint) with ordered steps.
|
|
71
|
+
#
|
|
72
|
+
# @param title [String] plan title
|
|
73
|
+
# @param steps [Array<Hash>] each: { title:, assignee:, labels: [] }
|
|
74
|
+
# @param creator [String] creating robot name
|
|
75
|
+
# @param ephemeral [Boolean] true for single-session plans (auto-gc)
|
|
76
|
+
# @return [Hash, nil] { plan:, steps: }
|
|
77
|
+
def create_plan(title, steps:, creator: "aia", ephemeral: false)
|
|
78
|
+
return nil unless available?
|
|
79
|
+
|
|
80
|
+
plan = TrakFlow::Models::Task.new(
|
|
81
|
+
title: title, plan: true, type: "task"
|
|
82
|
+
)
|
|
83
|
+
plan = @db.create_task(plan)
|
|
84
|
+
|
|
85
|
+
@db.add_label(TrakFlow::Models::Label.new(
|
|
86
|
+
task_id: plan.id, name: "creator:#{creator}"
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
prev_step = nil
|
|
90
|
+
step_tasks = steps.map.with_index do |step_def, _i|
|
|
91
|
+
step = @db.create_child_task(plan.id, {
|
|
92
|
+
title: step_def[:title],
|
|
93
|
+
assignee: step_def[:assignee],
|
|
94
|
+
type: "task"
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
Array(step_def[:labels]).each do |label|
|
|
98
|
+
@db.add_label(TrakFlow::Models::Label.new(
|
|
99
|
+
task_id: step.id, name: label
|
|
100
|
+
))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if prev_step
|
|
104
|
+
@db.add_dependency(TrakFlow::Models::Dependency.new(
|
|
105
|
+
source_id: prev_step.id, target_id: step.id, type: "blocks"
|
|
106
|
+
))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
prev_step = step
|
|
110
|
+
step
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
{ plan: plan, steps: step_tasks }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get ready tasks, optionally filtered by assignee.
|
|
117
|
+
#
|
|
118
|
+
# @param robot_name [String, nil] filter by assignee
|
|
119
|
+
# @return [Array<TrakFlow::Models::Task>]
|
|
120
|
+
def ready_tasks(robot_name: nil)
|
|
121
|
+
return [] unless available?
|
|
122
|
+
|
|
123
|
+
tasks = @db.ready_tasks
|
|
124
|
+
robot_name ? tasks.select { |t| t.assignee == robot_name } : tasks
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A robot claims a task (sets in_progress with its name).
|
|
128
|
+
#
|
|
129
|
+
# @param task_id [String]
|
|
130
|
+
# @param robot_name [String]
|
|
131
|
+
def claim_task(task_id, robot_name)
|
|
132
|
+
return unless available?
|
|
133
|
+
|
|
134
|
+
task = @db.find_task(task_id)
|
|
135
|
+
return unless task
|
|
136
|
+
|
|
137
|
+
task.status = "in_progress"
|
|
138
|
+
task.assignee = robot_name
|
|
139
|
+
task.append_trace("claimed", "Claimed by #{robot_name}")
|
|
140
|
+
@db.update_task(task)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A robot completes a task with a result summary.
|
|
144
|
+
#
|
|
145
|
+
# @param task_id [String]
|
|
146
|
+
# @param result [String] summary of what was done
|
|
147
|
+
# @param robot_name [String]
|
|
148
|
+
def complete_task(task_id, result:, robot_name:)
|
|
149
|
+
return unless available?
|
|
150
|
+
|
|
151
|
+
task = @db.find_task(task_id)
|
|
152
|
+
return unless task
|
|
153
|
+
|
|
154
|
+
task.close!(reason: result)
|
|
155
|
+
@db.add_comment(TrakFlow::Models::Comment.new(
|
|
156
|
+
task_id: task_id, author: robot_name, body: result
|
|
157
|
+
))
|
|
158
|
+
@db.update_task(task)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A robot marks a task as blocked with a reason.
|
|
162
|
+
#
|
|
163
|
+
# @param task_id [String]
|
|
164
|
+
# @param reason [String]
|
|
165
|
+
# @param robot_name [String]
|
|
166
|
+
def block_task(task_id, reason:, robot_name:)
|
|
167
|
+
return unless available?
|
|
168
|
+
|
|
169
|
+
task = @db.find_task(task_id)
|
|
170
|
+
return unless task
|
|
171
|
+
|
|
172
|
+
task.status = "blocked"
|
|
173
|
+
task.append_trace("blocked", "#{robot_name}: #{reason}")
|
|
174
|
+
@db.update_task(task)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Summary of the current task board state.
|
|
178
|
+
#
|
|
179
|
+
# @return [String, nil]
|
|
180
|
+
def status_summary
|
|
181
|
+
return nil unless available?
|
|
182
|
+
|
|
183
|
+
all = @db.list_tasks({})
|
|
184
|
+
ready = @db.ready_tasks
|
|
185
|
+
blocked = @db.blocked_tasks
|
|
186
|
+
|
|
187
|
+
by_assignee = all.select(&:assignee).group_by(&:assignee)
|
|
188
|
+
|
|
189
|
+
lines = ["Task Board (#{all.size} total, #{ready.size} ready, #{blocked.size} blocked):"]
|
|
190
|
+
by_assignee.sort.each do |assignee, tasks|
|
|
191
|
+
open_count = tasks.count { |t| t.open? || t.in_progress? }
|
|
192
|
+
closed_count = tasks.count(&:closed?)
|
|
193
|
+
lines << " #{assignee}: #{open_count} open, #{closed_count} done"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
unassigned = all.reject(&:assignee)
|
|
197
|
+
lines << " unassigned: #{unassigned.size}" unless unassigned.empty?
|
|
198
|
+
|
|
199
|
+
lines.join("\n")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/task_decomposer.rb
|
|
4
|
+
#
|
|
5
|
+
# Extracts plan decomposition logic from DelegateHandler.
|
|
6
|
+
# The lead robot analyzes a prompt and breaks it into subtasks
|
|
7
|
+
# with robot assignments.
|
|
8
|
+
|
|
9
|
+
require "json"
|
|
10
|
+
|
|
11
|
+
module AIA
|
|
12
|
+
class TaskDecomposer
|
|
13
|
+
include ContentExtractor
|
|
14
|
+
|
|
15
|
+
def initialize(lead_robot:, ui_presenter:)
|
|
16
|
+
@lead = lead_robot
|
|
17
|
+
@ui = ui_presenter
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Decompose a prompt into subtasks assigned to specific robots.
|
|
21
|
+
#
|
|
22
|
+
# @param prompt [String] the original user prompt
|
|
23
|
+
# @param robot_names [Array<String>] available robot names
|
|
24
|
+
# @return [Array<Hash>] steps with :title and :assignee, or [] on failure
|
|
25
|
+
def decompose(prompt, robot_names)
|
|
26
|
+
@ui.display_info("#{@lead.name} analyzing and delegating...")
|
|
27
|
+
|
|
28
|
+
plan_result = @lead.run(<<~PROMPT, mcp: :none, tools: :none)
|
|
29
|
+
Break this request into subtasks. Assign each to the most
|
|
30
|
+
appropriate team member based on their model capabilities.
|
|
31
|
+
|
|
32
|
+
Team: #{robot_names.join(', ')}
|
|
33
|
+
Request: #{prompt}
|
|
34
|
+
|
|
35
|
+
Respond with ONLY a JSON array:
|
|
36
|
+
[{"title": "subtask description", "assignee": "robot_name"}]
|
|
37
|
+
PROMPT
|
|
38
|
+
|
|
39
|
+
reply = extract_content(plan_result)
|
|
40
|
+
parse_plan(reply, robot_names)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def parse_plan(json_text, valid_names)
|
|
46
|
+
match = json_text.to_s.match(/\[.*\]/m)
|
|
47
|
+
return [] unless match
|
|
48
|
+
|
|
49
|
+
JSON.parse(match[0], symbolize_names: true).map do |step|
|
|
50
|
+
assignee = valid_names.include?(step[:assignee]) ? step[:assignee] : valid_names.first
|
|
51
|
+
{ title: step[:title].to_s, assignee: assignee }
|
|
52
|
+
end
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/task_executor.rb
|
|
4
|
+
#
|
|
5
|
+
# Extracts step execution logic from DelegateHandler.
|
|
6
|
+
# Runs a single subtask against the assigned robot and records
|
|
7
|
+
# the result in the task coordinator.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class TaskExecutor
|
|
11
|
+
include ContentExtractor
|
|
12
|
+
|
|
13
|
+
def initialize(task_coordinator:)
|
|
14
|
+
@coordinator = task_coordinator
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Execute a single task step against the assigned robot.
|
|
18
|
+
#
|
|
19
|
+
# @param task [Object] TrakFlow task object (responds to #id)
|
|
20
|
+
# @param robot [RobotLab::Robot] the assigned robot
|
|
21
|
+
# @param step_def [Hash] step definition with :title
|
|
22
|
+
# @param prompt [String] the original user prompt
|
|
23
|
+
# @param prior_results [Array<Hash>] results from previous steps
|
|
24
|
+
# @return [String] the robot's response content
|
|
25
|
+
def execute(task, robot, step_def, prompt, prior_results)
|
|
26
|
+
@coordinator.claim_task(task.id, robot.name)
|
|
27
|
+
|
|
28
|
+
context = build_step_context(prompt, step_def[:title], prior_results)
|
|
29
|
+
result = robot.run(context, mcp: :inherit, tools: :inherit)
|
|
30
|
+
content = extract_content(result)
|
|
31
|
+
|
|
32
|
+
@coordinator.complete_task(task.id, result: content[0, 200], robot_name: robot.name)
|
|
33
|
+
content
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def build_step_context(prompt, task_title, prior_results)
|
|
39
|
+
context = "Original request: #{prompt}\n\n"
|
|
40
|
+
|
|
41
|
+
unless prior_results.empty?
|
|
42
|
+
prior = prior_results.map do |r|
|
|
43
|
+
"#{r[:robot]} completed '#{r[:task]}':\n#{r[:content]}"
|
|
44
|
+
end.join("\n\n")
|
|
45
|
+
context += "Prior work:\n#{prior}\n\n"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context + "Your task: #{task_title}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/tfidf_math.rb
|
|
4
|
+
#
|
|
5
|
+
# Pure TF-IDF vector math shared by SimilarityScorer and ToolFilter::TFIDF.
|
|
6
|
+
# Kept dependency-free and stateless so both callers (and tests) can use it
|
|
7
|
+
# in isolation.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
module TFIDFMath
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Cosine similarity between two TF-IDF hash vectors.
|
|
14
|
+
#
|
|
15
|
+
# @param a [Hash{Symbol => Float}]
|
|
16
|
+
# @param b [Hash{Symbol => Float}]
|
|
17
|
+
# @return [Float] 0.0..1.0
|
|
18
|
+
def cosine_similarity(a, b)
|
|
19
|
+
all_keys = a.keys | b.keys
|
|
20
|
+
dot = all_keys.sum { |k| (a[k] || 0.0) * (b[k] || 0.0) }
|
|
21
|
+
mag_a = Math.sqrt(a.values.sum { |v| v**2 })
|
|
22
|
+
mag_b = Math.sqrt(b.values.sum { |v| v**2 })
|
|
23
|
+
return 0.0 if mag_a.zero? || mag_b.zero?
|
|
24
|
+
(dot / (mag_a * mag_b)).clamp(0.0, 1.0)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -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
|