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.
Files changed (170) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +231 -0
  4. data/.quality/flay_baseline.txt +1 -0
  5. data/.quality/flog_baseline.txt +29 -0
  6. data/.quality/reek_baseline.txt +80 -0
  7. data/.rubocop.yml +116 -0
  8. data/.version +1 -1
  9. data/CHANGELOG.md +266 -42
  10. data/IMPLEMENTATION_PLAN.md +506 -0
  11. data/README.md +266 -238
  12. data/Rakefile +118 -5
  13. data/architecture_review.md +314 -0
  14. data/bin/aia +16 -0
  15. data/docs/AGENTS.md +40 -0
  16. data/docs/advanced-prompting.md +67 -3
  17. data/docs/cli-reference.md +312 -56
  18. data/docs/configuration.md +130 -19
  19. data/docs/contributing.md +56 -2
  20. data/docs/directives-reference.md +593 -78
  21. data/docs/faq.md +85 -3
  22. data/docs/guides/available-models.md +1 -1
  23. data/docs/guides/basic-usage.md +6 -6
  24. data/docs/guides/chat.md +40 -16
  25. data/docs/guides/crew.md +239 -0
  26. data/docs/guides/executable-prompts.md +1 -1
  27. data/docs/guides/index.md +1 -0
  28. data/docs/guides/models.md +15 -0
  29. data/docs/index.md +29 -2
  30. data/docs/installation.md +44 -17
  31. data/docs/mcp-integration.md +40 -0
  32. data/docs/prompt_management.md +85 -86
  33. data/docs/security.md +47 -0
  34. data/docs/special_projects_guide.md +386 -0
  35. data/docs/tools-and-mcp-examples.md +23 -0
  36. data/docs/workflows-and-pipelines.md +84 -7
  37. data/examples/.gitignore +1 -0
  38. data/examples/00_setup_aia.sh +27 -44
  39. data/examples/11_multi_model.sh +4 -14
  40. data/examples/12_token_usage.sh +3 -12
  41. data/examples/18_tools.sh +10 -2
  42. data/examples/22_chat_mode.sh +0 -10
  43. data/examples/23_verify.sh +139 -0
  44. data/examples/24_decompose.sh +139 -0
  45. data/examples/25_spawn.sh +139 -0
  46. data/examples/26_debate.sh +97 -0
  47. data/examples/27_mention_routing.sh +157 -0
  48. data/examples/28_model_switching.sh +106 -0
  49. data/examples/29_agent_harness.sh +177 -0
  50. data/examples/README.md +65 -0
  51. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  52. data/examples/aia_config.yml +1 -1
  53. data/examples/aia_config_orchestrator.yml +45 -0
  54. data/examples/common.sh +19 -0
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/project_summary +2 -2
  57. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  58. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  59. data/examples/rules/01_classify_ruby.rb +16 -0
  60. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  61. data/examples/rules/03_gate_prompt_length.rb +19 -0
  62. data/examples/rules/04_tool_selection.rb +41 -0
  63. data/examples/rules/README.md +30 -0
  64. data/examples/run_all.sh +48 -15
  65. data/examples/tools/word_count_tool.rb +1 -1
  66. data/lib/AGENTS.md +57 -0
  67. data/lib/aia/chat_loop.rb +306 -159
  68. data/lib/aia/config/cli_parser.rb +174 -111
  69. data/lib/aia/config/defaults.yml +62 -33
  70. data/lib/aia/config/mcp_parser.rb +39 -46
  71. data/lib/aia/config/model_spec.rb +34 -2
  72. data/lib/aia/config/validator.rb +121 -138
  73. data/lib/aia/config.rb +110 -145
  74. data/lib/aia/content_extractor.rb +153 -0
  75. data/lib/aia/cost_calculator.rb +38 -0
  76. data/lib/aia/crew.rb +164 -0
  77. data/lib/aia/debate_handler.rb +166 -0
  78. data/lib/aia/delegate_handler.rb +112 -0
  79. data/lib/aia/directive.rb +33 -18
  80. data/lib/aia/directive_processor.rb +16 -7
  81. data/lib/aia/directives/configuration_directives.rb +160 -20
  82. data/lib/aia/directives/context_directives.rb +38 -26
  83. data/lib/aia/directives/execution_directives.rb +136 -4
  84. data/lib/aia/directives/model_directives.rb +76 -34
  85. data/lib/aia/directives/trakflow_directives.rb +44 -0
  86. data/lib/aia/directives/utility_directives.rb +203 -6
  87. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  88. data/lib/aia/errors.rb +15 -0
  89. data/lib/aia/fact_asserter.rb +27 -0
  90. data/lib/aia/fzf.rb +9 -31
  91. data/lib/aia/handler_context.rb +17 -0
  92. data/lib/aia/handler_protocol.rb +19 -0
  93. data/lib/aia/history_transfer.rb +55 -0
  94. data/lib/aia/input_collector.rb +3 -3
  95. data/lib/aia/layered_orchestrator.rb +448 -0
  96. data/lib/aia/logger.rb +24 -4
  97. data/lib/aia/mcp_config_normalizer.rb +35 -0
  98. data/lib/aia/mcp_connection_manager.rb +305 -0
  99. data/lib/aia/mcp_discovery.rb +44 -0
  100. data/lib/aia/mcp_grouper.rb +33 -0
  101. data/lib/aia/mcp_utility.rb +57 -0
  102. data/lib/aia/mention_router.rb +260 -0
  103. data/lib/aia/model_alias_registry.rb +97 -0
  104. data/lib/aia/model_switch_handler.rb +100 -0
  105. data/lib/aia/network_builder.rb +155 -0
  106. data/lib/aia/network_memory_manager.rb +55 -0
  107. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  108. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  109. data/lib/aia/pipeline_orchestrator.rb +262 -0
  110. data/lib/aia/plugin_loader.rb +170 -0
  111. data/lib/aia/plugin_monitor.rb +208 -0
  112. data/lib/aia/prompt_decomposer.rb +157 -0
  113. data/lib/aia/prompt_handler.rb +19 -39
  114. data/lib/aia/robot_builder.rb +51 -0
  115. data/lib/aia/robot_factory.rb +334 -0
  116. data/lib/aia/robot_namer.rb +116 -0
  117. data/lib/aia/session.rb +83 -17
  118. data/lib/aia/session_tracker.rb +209 -0
  119. data/lib/aia/similarity_scorer.rb +39 -0
  120. data/lib/aia/skill_utils.rb +105 -1
  121. data/lib/aia/spawn_handler.rb +129 -0
  122. data/lib/aia/spawn_spec_parser.rb +65 -0
  123. data/lib/aia/special_mode_handler.rb +302 -0
  124. data/lib/aia/startup_coordinator.rb +150 -0
  125. data/lib/aia/streaming_runner.rb +169 -0
  126. data/lib/aia/system_prompt_assembler.rb +88 -0
  127. data/lib/aia/task_coordinator.rb +202 -0
  128. data/lib/aia/task_decomposer.rb +57 -0
  129. data/lib/aia/task_executor.rb +51 -0
  130. data/lib/aia/tfidf_math.rb +27 -0
  131. data/lib/aia/tool_filter/tfidf.rb +116 -0
  132. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  133. data/lib/aia/tool_filter.rb +82 -0
  134. data/lib/aia/tool_filter_registry.rb +30 -0
  135. data/lib/aia/tool_filter_strategy.rb +143 -0
  136. data/lib/aia/tool_loader.rb +210 -0
  137. data/lib/aia/tool_utility.rb +30 -0
  138. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  139. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  140. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  141. data/lib/aia/tools/task_board_tool.rb +114 -0
  142. data/lib/aia/trakflow_bridge.rb +173 -0
  143. data/lib/aia/turn_state.rb +94 -0
  144. data/lib/aia/ui_presenter.rb +166 -198
  145. data/lib/aia/utility.rb +134 -87
  146. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  147. data/lib/aia/verification_network.rb +58 -0
  148. data/lib/aia.rb +108 -63
  149. data/mkdocs.yml +1 -0
  150. metadata +179 -56
  151. data/justfile +0 -215
  152. data/lib/aia/adapter/chat_execution.rb +0 -242
  153. data/lib/aia/adapter/error_handler.rb +0 -68
  154. data/lib/aia/adapter/gem_activator.rb +0 -57
  155. data/lib/aia/adapter/mcp_connector.rb +0 -274
  156. data/lib/aia/adapter/modality_handlers.rb +0 -167
  157. data/lib/aia/adapter/model_registry.rb +0 -81
  158. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  159. data/lib/aia/adapter/provider_configurator.rb +0 -59
  160. data/lib/aia/adapter/tool_filter.rb +0 -85
  161. data/lib/aia/adapter/tool_loader.rb +0 -90
  162. data/lib/aia/chat_processor_service.rb +0 -164
  163. data/lib/aia/prompt_pipeline.rb +0 -183
  164. data/lib/aia/ruby_llm_adapter.rb +0 -95
  165. data/lib/extensions/openstruct_merge.rb +0 -48
  166. data/lib/extensions/ruby_llm/.irbrc +0 -56
  167. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  168. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  169. data/lib/refinements/string.rb +0 -16
  170. 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