aia 1.1.1 → 2.0.0.0.pre.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +9 -1
  3. data/.loki +11 -0
  4. data/.reek.yml +160 -0
  5. data/.rubocop.yml +116 -0
  6. data/.rubocop_strict.yml +15 -0
  7. data/.version +1 -1
  8. data/CHANGELOG.md +269 -55
  9. data/IMPLEMENTATION_PLAN.md +506 -0
  10. data/README.md +267 -239
  11. data/Rakefile +5 -5
  12. data/_typos.toml +10 -0
  13. data/aia.gemspec +92 -0
  14. data/architecture_review.md +314 -0
  15. data/bin/aia +16 -0
  16. data/config/aia.yml +13 -0
  17. data/docs/AGENTS.md +40 -0
  18. data/docs/advanced-prompting.md +67 -3
  19. data/docs/cli-reference.md +312 -56
  20. data/docs/configuration.md +130 -19
  21. data/docs/contributing.md +56 -2
  22. data/docs/directives-reference.md +593 -78
  23. data/docs/faq.md +85 -3
  24. data/docs/guides/available-models.md +1 -1
  25. data/docs/guides/basic-usage.md +6 -6
  26. data/docs/guides/chat.md +40 -16
  27. data/docs/guides/crew.md +239 -0
  28. data/docs/guides/executable-prompts.md +1 -1
  29. data/docs/guides/index.md +1 -0
  30. data/docs/guides/models.md +15 -0
  31. data/docs/index.md +29 -2
  32. data/docs/installation.md +44 -17
  33. data/docs/mcp-integration.md +40 -0
  34. data/docs/prompt_management.md +85 -86
  35. data/docs/security.md +47 -0
  36. data/docs/special_projects_guide.md +386 -0
  37. data/docs/tools-and-mcp-examples.md +23 -0
  38. data/docs/workflows-and-pipelines.md +84 -7
  39. data/examples/.gitignore +1 -0
  40. data/examples/00_setup_aia.sh +27 -44
  41. data/examples/11_multi_model.sh +4 -14
  42. data/examples/12_token_usage.sh +3 -12
  43. data/examples/18_tools.sh +10 -2
  44. data/examples/22_chat_mode.sh +0 -10
  45. data/examples/23_verify.sh +139 -0
  46. data/examples/24_decompose.sh +139 -0
  47. data/examples/25_spawn.sh +139 -0
  48. data/examples/26_debate.sh +97 -0
  49. data/examples/27_mention_routing.sh +157 -0
  50. data/examples/28_model_switching.sh +106 -0
  51. data/examples/29_agent_harness.sh +177 -0
  52. data/examples/README.md +65 -0
  53. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  54. data/examples/aia_config.yml +1 -1
  55. data/examples/aia_config_orchestrator.yml +45 -0
  56. data/examples/common.sh +18 -6
  57. data/examples/context/tech_stack.md +2 -2
  58. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  59. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  60. data/examples/rules/01_classify_ruby.rb +16 -0
  61. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  62. data/examples/rules/03_gate_prompt_length.rb +19 -0
  63. data/examples/rules/04_tool_selection.rb +41 -0
  64. data/examples/rules/README.md +30 -0
  65. data/examples/run_all.sh +48 -15
  66. data/examples/tools/word_count_tool.rb +1 -1
  67. data/lib/AGENTS.md +57 -0
  68. data/lib/aia/chat_loop.rb +263 -167
  69. data/lib/aia/config/cli_parser.rb +217 -145
  70. data/lib/aia/config/defaults.yml +62 -33
  71. data/lib/aia/config/mcp_parser.rb +52 -51
  72. data/lib/aia/config/model_spec.rb +34 -2
  73. data/lib/aia/config/validator.rb +171 -216
  74. data/lib/aia/config.rb +111 -145
  75. data/lib/aia/content_extractor.rb +155 -0
  76. data/lib/aia/cost_calculator.rb +39 -0
  77. data/lib/aia/crew.rb +164 -0
  78. data/lib/aia/debate_handler.rb +174 -0
  79. data/lib/aia/delegate_handler.rb +116 -0
  80. data/lib/aia/directive.rb +43 -26
  81. data/lib/aia/directive_processor.rb +16 -7
  82. data/lib/aia/directives/configuration_directives.rb +214 -60
  83. data/lib/aia/directives/context_directives.rb +67 -52
  84. data/lib/aia/directives/execution_directives.rb +141 -4
  85. data/lib/aia/directives/model_directives.rb +163 -141
  86. data/lib/aia/directives/trakflow_directives.rb +62 -0
  87. data/lib/aia/directives/utility_directives.rb +227 -30
  88. data/lib/aia/directives/web_and_file_directives.rb +126 -77
  89. data/lib/aia/errors.rb +15 -0
  90. data/lib/aia/fact_asserter.rb +27 -0
  91. data/lib/aia/fzf.rb +9 -31
  92. data/lib/aia/handler_context.rb +17 -0
  93. data/lib/aia/handler_protocol.rb +19 -0
  94. data/lib/aia/history_transfer.rb +55 -0
  95. data/lib/aia/input_collector.rb +3 -3
  96. data/lib/aia/layered_orchestrator.rb +471 -0
  97. data/lib/aia/logger.rb +45 -25
  98. data/lib/aia/mcp_config_normalizer.rb +35 -0
  99. data/lib/aia/mcp_connection_manager.rb +315 -0
  100. data/lib/aia/mcp_discovery.rb +44 -0
  101. data/lib/aia/mcp_grouper.rb +33 -0
  102. data/lib/aia/mcp_server_config.rb +30 -0
  103. data/lib/aia/mcp_utility.rb +60 -0
  104. data/lib/aia/mention_router.rb +217 -0
  105. data/lib/aia/model_alias_registry.rb +97 -0
  106. data/lib/aia/model_switch_handler.rb +100 -0
  107. data/lib/aia/network_builder.rb +160 -0
  108. data/lib/aia/network_memory_manager.rb +55 -0
  109. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  110. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  111. data/lib/aia/pipeline_orchestrator.rb +272 -0
  112. data/lib/aia/plugin_loader.rb +170 -0
  113. data/lib/aia/plugin_monitor.rb +211 -0
  114. data/lib/aia/prompt_decomposer.rb +159 -0
  115. data/lib/aia/prompt_handler.rb +56 -82
  116. data/lib/aia/robot_builder.rb +51 -0
  117. data/lib/aia/robot_factory.rb +338 -0
  118. data/lib/aia/robot_namer.rb +110 -0
  119. data/lib/aia/session.rb +87 -17
  120. data/lib/aia/session_tracker.rb +207 -0
  121. data/lib/aia/similarity_scorer.rb +41 -0
  122. data/lib/aia/skill_utils.rb +105 -1
  123. data/lib/aia/spawn_handler.rb +129 -0
  124. data/lib/aia/spawn_spec_parser.rb +65 -0
  125. data/lib/aia/special_mode_handler.rb +322 -0
  126. data/lib/aia/speech.rb +67 -0
  127. data/lib/aia/startup_coordinator.rb +151 -0
  128. data/lib/aia/streaming_runner.rb +172 -0
  129. data/lib/aia/system_prompt_assembler.rb +92 -0
  130. data/lib/aia/task_coordinator.rb +207 -0
  131. data/lib/aia/task_decomposer.rb +57 -0
  132. data/lib/aia/task_executor.rb +51 -0
  133. data/lib/aia/tfidf_math.rb +27 -0
  134. data/lib/aia/timing.rb +15 -0
  135. data/lib/aia/tool_filter/tfidf.rb +116 -0
  136. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  137. data/lib/aia/tool_filter.rb +83 -0
  138. data/lib/aia/tool_filter_registry.rb +30 -0
  139. data/lib/aia/tool_filter_strategy.rb +146 -0
  140. data/lib/aia/tool_introspection.rb +17 -0
  141. data/lib/aia/tool_loader.rb +216 -0
  142. data/lib/aia/tool_utility.rb +30 -0
  143. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  144. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  145. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  146. data/lib/aia/tools/task_board_tool.rb +115 -0
  147. data/lib/aia/trakflow_bridge.rb +175 -0
  148. data/lib/aia/turn_state.rb +95 -0
  149. data/lib/aia/ui_presenter.rb +182 -206
  150. data/lib/aia/utility.rb +136 -87
  151. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
  152. data/lib/aia/verification_network.rb +57 -0
  153. data/lib/aia.rb +124 -63
  154. data/mkdocs.yml +1 -0
  155. metadata +187 -58
  156. data/justfile +0 -215
  157. data/lib/aia/adapter/chat_execution.rb +0 -242
  158. data/lib/aia/adapter/error_handler.rb +0 -68
  159. data/lib/aia/adapter/gem_activator.rb +0 -57
  160. data/lib/aia/adapter/mcp_connector.rb +0 -274
  161. data/lib/aia/adapter/modality_handlers.rb +0 -167
  162. data/lib/aia/adapter/model_registry.rb +0 -81
  163. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  164. data/lib/aia/adapter/provider_configurator.rb +0 -59
  165. data/lib/aia/adapter/tool_filter.rb +0 -85
  166. data/lib/aia/adapter/tool_loader.rb +0 -90
  167. data/lib/aia/chat_processor_service.rb +0 -178
  168. data/lib/aia/prompt_pipeline.rb +0 -183
  169. data/lib/aia/ruby_llm_adapter.rb +0 -95
  170. data/lib/extensions/openstruct_merge.rb +0 -48
  171. data/lib/extensions/ruby_llm/.irbrc +0 -56
  172. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  173. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  174. data/lib/refinements/string.rb +0 -16
  175. data/main.just +0 -76
data/lib/aia/crew.rb ADDED
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'skill_utils'
4
+
5
+ module AIA
6
+ # Runtime management of the chat session's crew — the Network behind
7
+ # AIA.client. After RobotFactory wraps every session in a crew, members can be
8
+ # added and removed mid-conversation. Both the user (via /add_recruit and
9
+ # /drop_recruit) and a robot (via a tool) funnel through here.
10
+ module Crew
11
+ module_function
12
+
13
+ # Recruit a new robot into the running crew. The newcomer is spawned from
14
+ # the crew's chief (sharing its bus), optionally on an explicit
15
+ # model/provider, then added to the crew so it shows in /robots and answers
16
+ # to @name. With no model it inherits the chief's model and provider.
17
+ #
18
+ # @param spec [Hash] {name:, model:, provider:, system_prompt:, skills:} (e.g. from SpawnSpecParser)
19
+ # @return [RobotLab::Robot] the recruited robot
20
+ # @raise [AIA::CrewError]
21
+ def recruit(spec)
22
+ crew = require_crew
23
+ chief = crew.chief
24
+ name = spec[:name].to_s
25
+ if name.casecmp?(MentionRouter::BROADCAST_TOKEN)
26
+ raise CrewError, "'#{name}' is reserved (@crew broadcasts to every member)."
27
+ end
28
+ raise CrewError, "A crewmate named '#{name}' already exists." if member?(crew, name)
29
+
30
+ robot = chief.spawn(
31
+ name: name,
32
+ system_prompt: compose_system_prompt(spec),
33
+ local_tools: chief_local_tools(chief),
34
+ **model_opts(spec)
35
+ )
36
+ inherit_mcp(robot, chief)
37
+ crew.add_robot(robot)
38
+ robot
39
+ end
40
+
41
+ # Reset an existing crewmate to a clean slate and re-skill it. The member is
42
+ # dropped and re-recruited from the chief on the same model/provider, with a
43
+ # fresh conversation and the given skills/system prompt as its new role. The
44
+ # chief cannot be reskilled.
45
+ #
46
+ # @param name [String]
47
+ # @param skills [Array<String>, nil] skill ids to assign as the new role
48
+ # @param system_prompt [String, nil] extra system prompt appended after skills
49
+ # @return [RobotLab::Robot] the re-skilled robot
50
+ # @raise [AIA::CrewError]
51
+ def reskill(name, skills: nil, system_prompt: nil)
52
+ crew = require_crew
53
+ name = name.to_s
54
+ old = find_member(crew, name)
55
+ raise CrewError, "No crewmate named '#{name}'." unless old
56
+ raise CrewError, "Cannot reskill the chief '#{name}'." if crew.chief&.name == name
57
+
58
+ crew.remove_robot(name)
59
+ recruit(
60
+ name: name, model: old.model, provider: old.provider,
61
+ skills: skills, system_prompt: system_prompt
62
+ )
63
+ end
64
+
65
+ # Drop a crewmate by name. The chief (the session's lead robot) cannot be
66
+ # dropped.
67
+ #
68
+ # @param name [String]
69
+ # @return [RobotLab::Robot] the removed robot
70
+ # @raise [AIA::CrewError]
71
+ def drop(name)
72
+ crew = require_crew
73
+ name = name.to_s
74
+ raise CrewError, "No crewmate named '#{name}'." unless member?(crew, name)
75
+ raise CrewError, "Cannot drop the chief '#{name}'." if crew.chief&.name == name
76
+
77
+ crew.remove_robot(name)
78
+ end
79
+
80
+ # The crew (Network) backing the current session.
81
+ #
82
+ # @return [RobotLab::Network]
83
+ # @raise [AIA::CrewError] when the session isn't a crew
84
+ def require_crew
85
+ crew = AIA.client
86
+ raise CrewError, "The active session is not a crew; cannot manage members." unless crew.respond_to?(:add_robot)
87
+
88
+ crew
89
+ end
90
+
91
+ # @return [Boolean]
92
+ def member?(crew, name)
93
+ crew.crew.any? { |robot| robot.name == name }
94
+ end
95
+
96
+ # @return [RobotLab::Robot, nil]
97
+ def find_member(crew, name)
98
+ crew.crew.find { |robot| robot.name == name }
99
+ end
100
+
101
+ # Build the recruit's system prompt (its role) from any assigned skills
102
+ # followed by an explicit system prompt. Falls back to a simple default when
103
+ # neither is given.
104
+ #
105
+ # @return [String]
106
+ def compose_system_prompt(spec)
107
+ parts = [skills_prompt(spec[:skills]), spec[:system_prompt]].compact
108
+ parts.empty? ? "You are #{spec[:name]}." : parts.join("\n\n")
109
+ end
110
+
111
+ # Load the bodies of the named skills, joined, to seed a recruit's role.
112
+ # Raises so the caller (user or chief) hears about an unknown skill rather
113
+ # than silently recruiting a robot without the role it was meant to have.
114
+ #
115
+ # @param skill_ids [Array<String>, nil]
116
+ # @return [String, nil]
117
+ # @raise [AIA::CrewError]
118
+ def skills_prompt(skill_ids)
119
+ ids = Array(skill_ids).map(&:to_s).reject(&:empty?)
120
+ return nil if ids.empty?
121
+
122
+ base = AIA::SkillUtils.skills_base_dir(AIA.config)
123
+ raise CrewError, "No skills directory is configured." unless base
124
+
125
+ missing = ids.reject { |id| AIA::SkillUtils.find_skill_dir(id, base) }
126
+ raise CrewError, "Skill(s) not found: #{missing.join(', ')}." unless missing.empty?
127
+
128
+ AIA::SkillUtils.load_skills_content(ids, base)
129
+ end
130
+
131
+ # Model/provider override for an explicit recruit. When a model is given the
132
+ # provider is passed too (even nil) so it overrides the inherited chief
133
+ # provider; with no model, {} lets the spawn inherit chief model + provider.
134
+ #
135
+ # @return [Hash]
136
+ def model_opts(spec)
137
+ return {} unless spec[:model]
138
+
139
+ { model: spec[:model], provider: spec[:provider] }
140
+ end
141
+
142
+ # The chief's local tool instances, shared with the recruit so it can do the
143
+ # same work (file/shell/etc.). Tool instances are effectively stateless, so
144
+ # sharing them across crew members is safe.
145
+ #
146
+ # @return [Array]
147
+ def chief_local_tools(chief)
148
+ chief.respond_to?(:local_tools) ? Array(chief.local_tools) : []
149
+ end
150
+
151
+ # Hand the recruit the chief's already-connected MCP clients and tools rather
152
+ # than opening fresh connections.
153
+ #
154
+ # @return [void]
155
+ def inherit_mcp(robot, chief)
156
+ return unless robot.respond_to?(:inject_mcp!) && chief.respond_to?(:mcp_clients)
157
+
158
+ clients = chief.mcp_clients
159
+ return if clients.nil? || clients.empty?
160
+
161
+ robot.inject_mcp!(clients: clients, tools: Array(chief.mcp_tools))
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/debate_handler.rb
4
+ #
5
+ # Multi-round debate between robots in a network.
6
+ # Each robot responds to the previous round's outputs until
7
+ # convergence is reached or a round limit is hit.
8
+ # Uses TypedBus for bus attachment and SharedMemory for state.
9
+ # Uses Async::Barrier for concurrent robot execution per round.
10
+
11
+ require 'async'
12
+
13
+ module AIA
14
+ class DebateHandler
15
+ include ContentExtractor
16
+ include HandlerProtocol
17
+
18
+ MAX_ROUNDS = 5
19
+ MIN_ROUNDS = 2
20
+ SIMILARITY_THRESHOLD = 0.85
21
+ CONVERGENCE_SIGNAL = "CONVERGED"
22
+
23
+ # Sentinel value for a robot that failed to respond in a round.
24
+ # Treated as empty string for convergence; rendered with [FAILED] marker.
25
+ FailedResponse = Data.define(:robot_name, :error_message)
26
+
27
+ def initialize(robot:, ui_presenter:, tracker:)
28
+ @robot = robot
29
+ @ui_presenter = ui_presenter
30
+ @tracker = tracker
31
+ end
32
+
33
+ attr_writer :robot
34
+
35
+ # Run a debate between robots in the network.
36
+ #
37
+ # @param context [HandlerContext] — reads context.prompt
38
+ # @return [String, nil] formatted debate results, or nil if not applicable
39
+ # :reek:TooManyStatements -- debate orchestration: per-round async fan-out, convergence check, tracking; stages share round state
40
+ def handle(context)
41
+ prompt = context.prompt
42
+ return nil unless @robot.network?
43
+
44
+ robots = @robot.crew
45
+ return nil if robots.size < 2
46
+
47
+ # Ensure all robots share a bus
48
+ bus = TypedBus::MessageBus.new
49
+ robots.each { |r| r.with_bus(bus) }
50
+
51
+ @ui_presenter.display_info(
52
+ "Debate between #{robots.map(&:name).join(', ')}..."
53
+ )
54
+
55
+ rounds = []
56
+
57
+ MAX_ROUNDS.times do |round|
58
+ round_no = round + 1
59
+ round_context = build_round_context(prompt, rounds)
60
+
61
+ round_results = run_debate_round(robots, round_context, round, round_no)
62
+
63
+ raise DebateError, "All robots failed in round #{round_no}" if
64
+ round_results.all?(FailedResponse)
65
+
66
+ previous = rounds.last
67
+ rounds << round_results
68
+
69
+ if converged?(round, round_results, previous)
70
+ @ui_presenter.display_info(" Converged in round #{round_no}.")
71
+ break
72
+ end
73
+ end
74
+
75
+ @tracker.record_turn(
76
+ model: AIA.config.models.first.name,
77
+ input: prompt,
78
+ result: rounds
79
+ )
80
+
81
+ format_rounds(rounds)
82
+ end
83
+
84
+ private
85
+
86
+ # Run every robot once against the round context, concurrently.
87
+ # Returns one entry per robot: a result hash, or FailedResponse on error.
88
+ # :reek:TooManyStatements -- async fan-out with per-robot progress, memory write, and failure capture
89
+ def run_debate_round(robots, round_context, round, round_no)
90
+ Sync do
91
+ barrier = Async::Barrier.new
92
+ tasks = robots.map do |robot|
93
+ barrier.async do
94
+ name = robot.name
95
+ @ui_presenter.display_info(" Round #{round_no}: #{name}...")
96
+ result = robot.run(round_context, mcp: :inherit, tools: :inherit)
97
+ content = extract_content(result)
98
+ write_to_memory(round, name, content)
99
+ { robot: name, content: content }
100
+ rescue => e
101
+ FailedResponse.new(robot_name: name, error_message: e.message)
102
+ end
103
+ end
104
+ barrier.wait
105
+ tasks.map(&:wait)
106
+ end
107
+ end
108
+
109
+ def build_round_context(prompt, rounds)
110
+ return prompt if rounds.empty?
111
+
112
+ previous = rounds.last.map do |r|
113
+ if r.is_a?(FailedResponse)
114
+ "#{r.robot_name}: [FAILED: #{r.error_message}]"
115
+ else
116
+ "#{r[:robot]}: #{r[:content]}"
117
+ end
118
+ end.join("\n\n")
119
+
120
+ <<~CONTEXT
121
+ Topic: #{prompt}
122
+
123
+ Previous round:
124
+ #{previous}
125
+
126
+ Respond to the points above. Say CONVERGED if you agree with the overall consensus and provide a final summary. Otherwise refine your position.
127
+ CONTEXT
128
+ end
129
+
130
+ def converged?(round_index, current_results, previous_results)
131
+ return false if round_index < MIN_ROUNDS - 1
132
+
133
+ # Fast path: every robot explicitly signaled convergence.
134
+ return true if all_signaled_convergence?(current_results)
135
+
136
+ return false if previous_results.nil?
137
+
138
+ current_text = current_results.map { |r| r.is_a?(FailedResponse) ? "" : r[:content].to_s }.join(" ")
139
+ previous_text = previous_results.map { |r| r.is_a?(FailedResponse) ? "" : r[:content].to_s }.join(" ")
140
+
141
+ scores = SimilarityScorer.score([previous_text, current_text])
142
+ (scores[1] || 0.0) >= SIMILARITY_THRESHOLD
143
+ end
144
+
145
+ def all_signaled_convergence?(round_results)
146
+ round_results.all? do |r|
147
+ next false if r.is_a?(FailedResponse)
148
+ r[:content].to_s.upcase.include?(CONVERGENCE_SIGNAL)
149
+ end
150
+ end
151
+
152
+ def write_to_memory(round, robot_name, content)
153
+ return unless @robot.respond_to?(:memory)
154
+
155
+ @robot.memory.current_writer = robot_name
156
+ @robot.memory.set(:"debate_r#{round}_#{robot_name}", content)
157
+ end
158
+
159
+ def format_rounds(rounds)
160
+ lines = []
161
+ rounds.each_with_index do |round, i|
162
+ lines << "### Round #{i + 1}"
163
+ round.each do |entry|
164
+ lines << if entry.is_a?(FailedResponse)
165
+ "**#{entry.robot_name}**: [FAILED] #{entry.error_message}\n"
166
+ else
167
+ "**#{entry[:robot]}**: #{entry[:content]}\n"
168
+ end
169
+ end
170
+ end
171
+ lines.join("\n")
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/delegate_handler.rb
4
+ #
5
+ # Lead robot analyzes a prompt, creates a TrakFlow plan with steps
6
+ # assigned to specific robots, then each robot executes its tasks
7
+ # in dependency order. Combines TypedBus, SharedMemory, and TrakFlow.
8
+ #
9
+ # Decomposition is delegated to TaskDecomposer.
10
+ # Step execution is delegated to TaskExecutor.
11
+
12
+ module AIA
13
+ class DelegateHandler
14
+ include ContentExtractor
15
+ include HandlerProtocol
16
+
17
+ def initialize(robot:, ui_presenter:, tracker:, task_coordinator:)
18
+ @robot = robot
19
+ @ui_presenter = ui_presenter
20
+ @tracker = tracker
21
+ @task_coordinator = task_coordinator
22
+ end
23
+
24
+ attr_writer :robot
25
+
26
+ # Decompose a prompt into subtasks, assign to robots, and execute.
27
+ #
28
+ # @param context [HandlerContext] — reads context.prompt
29
+ # @return [String, nil] combined results, or nil if not applicable
30
+ # :reek:TooManyStatements -- three-step delegation pipeline (decompose, plan, execute) sharing intermediate results
31
+ def handle(context)
32
+ prompt = context.prompt
33
+ return nil unless @robot.network?
34
+ return nil unless @task_coordinator&.available?
35
+
36
+ robots = @robot.robots
37
+ robot_names = robots.values.map(&:name)
38
+ lead = robots.values.first
39
+
40
+ # Step 1: Decompose the work
41
+ decomposer = TaskDecomposer.new(lead_robot: lead, ui_presenter: @ui_presenter)
42
+ steps = decomposer.decompose(prompt, robot_names)
43
+
44
+ if steps.empty?
45
+ @ui_presenter.display_info("Could not decompose into subtasks.")
46
+ return nil
47
+ end
48
+
49
+ # Step 2: Create TrakFlow plan
50
+ plan = @task_coordinator.create_plan(
51
+ "Delegated: #{prompt[0, 60]}",
52
+ steps: steps, creator: lead.name, ephemeral: true
53
+ )
54
+
55
+ @ui_presenter.display_info("Plan with #{steps.size} tasks:")
56
+ steps.each_with_index do |s, i|
57
+ @ui_presenter.display_info(" #{i + 1}. #{s[:title]} -> #{s[:assignee]}")
58
+ end
59
+
60
+ # Step 3: Execute tasks in order
61
+ executor = TaskExecutor.new(task_coordinator: @task_coordinator)
62
+ results = execute_steps(executor, prompt, plan, steps, robots)
63
+
64
+ @tracker.record_turn(
65
+ model: AIA.config.models.first.name,
66
+ input: prompt,
67
+ result: results
68
+ )
69
+
70
+ format_results(results)
71
+ end
72
+
73
+ private
74
+
75
+ # :reek:TooManyStatements -- per-step loop: resolve assignee, announce, execute, record to memory and results
76
+ def execute_steps(executor, prompt, plan, steps, robots)
77
+ results = []
78
+
79
+ plan[:steps].each_with_index do |task, i|
80
+ step_def = steps[i]
81
+ assignee = robots.values.find { |r| r.name == step_def[:assignee] }
82
+ assignee ||= robots.values.first
83
+ name = assignee.name
84
+ title = step_def[:title]
85
+
86
+ @ui_presenter.display_info(" #{name}: #{title}...")
87
+
88
+ content = executor.execute(task, assignee, step_def, prompt, results)
89
+
90
+ write_to_memory(i, name, title, content)
91
+ results << { robot: name, task: title, content: content }
92
+ end
93
+
94
+ results
95
+ end
96
+
97
+ def write_to_memory(index, robot_name, task_title, content)
98
+ return unless @robot.respond_to?(:memory)
99
+
100
+ @robot.memory.current_writer = robot_name
101
+ @robot.memory.set(:"delegate_step_#{index}", {
102
+ robot: robot_name, task: task_title, content: content
103
+ })
104
+ end
105
+
106
+ def format_results(results)
107
+ lines = []
108
+ results.each_with_index do |r, i|
109
+ lines << "### Step #{i + 1}: #{r[:task]} (#{r[:robot]})"
110
+ lines << r[:content]
111
+ lines << ""
112
+ end
113
+ lines.join("\n")
114
+ end
115
+ end
116
+ end
data/lib/aia/directive.rb CHANGED
@@ -34,9 +34,28 @@
34
34
 
35
35
  module AIA
36
36
  class Directive < PM::Directive
37
- DIRECTIVE_PREFIX = '/'
37
+ DIRECTIVE_PREFIX = '/'.freeze
38
38
 
39
39
  class << self
40
+ # ---- State-setting DSL -----------------------------------------------
41
+ # Directives that mutate turn-state or set a mode flag (rather than
42
+ # returning text for the AI) should declare themselves here so that
43
+ # ChatLoop can ask the processor instead of hardcoding a name list.
44
+ #
45
+ # Usage (in a subclass body):
46
+ # state_setting! :verify, :decompose, :concurrent, :conc
47
+ #
48
+ # The names must match the directive method names (without the / prefix).
49
+
50
+ def state_setting!(*method_names)
51
+ @state_setting_methods ||= []
52
+ @state_setting_methods.concat(method_names.map(&:to_s))
53
+ end
54
+
55
+ def state_setting_methods
56
+ @state_setting_methods || []
57
+ end
58
+
40
59
  # ---- Dispatch override ------------------------------------------------
41
60
  # AIA directive methods use (args_array, context_manager) convention,
42
61
  # not (ctx, *args). Override build_dispatch_block to adapt.
@@ -49,6 +68,7 @@ module AIA
49
68
  # Application-level concern: formats help text using DIRECTIVE_PREFIX
50
69
  # and all registered directive subclasses.
51
70
 
71
+ # :reek:TooManyStatements -- sequential terminal report: header, one section per directive category, footer
52
72
  def help
53
73
  puts
54
74
  puts "Available Directives"
@@ -65,14 +85,7 @@ module AIA
65
85
  puts "-" * cat.length
66
86
 
67
87
  klass.directive_descriptions.each do |method_name, description|
68
- aliases = klass.directive_aliases[method_name] || []
69
- alias_text = if aliases.any?
70
- " (aliases: #{aliases.map { |a| "#{DIRECTIVE_PREFIX}#{a}" }.join(', ')})"
71
- else
72
- ""
73
- end
74
-
75
- puts " #{DIRECTIVE_PREFIX}#{method_name}#{alias_text}"
88
+ puts " #{DIRECTIVE_PREFIX}#{method_name}#{alias_text_for(klass, method_name)}"
76
89
  puts " #{description}"
77
90
  puts
78
91
 
@@ -83,31 +96,35 @@ module AIA
83
96
  puts "\nTotal: #{total} directives available"
84
97
  ""
85
98
  end
99
+
100
+ # Format the "(aliases: ...)" suffix for a directive, or "" when it has none.
101
+ def alias_text_for(klass, method_name)
102
+ aliases = klass.directive_aliases[method_name] || []
103
+ return "" if aliases.empty?
104
+
105
+ " (aliases: #{aliases.map { |a| "#{DIRECTIVE_PREFIX}#{a}" }.join(', ')})"
106
+ end
86
107
  end
87
108
 
88
109
  private
89
110
 
90
- # Split args into positive and negative search terms.
91
- # Tokens prefixed with -, ~, or ! are negative (exclusion) terms.
92
- # All other tokens (bare or +-prefixed) are positive (inclusion) terms.
93
- # All terms are downcased.
94
- #
95
- # @param args [Array<String>] raw argument tokens
96
- # @return [Array<Array<String>>] [positive_terms, negative_terms]
111
+ # Split raw args into [positive_terms, negative_terms].
112
+ # Leading '+' is stripped; the term is treated as positive.
113
+ # Leading '-', '~', or '!' marks the term as negative (AND NOT).
114
+ # Bare terms are positive. All terms are lowercased.
97
115
  def parse_search_terms(args)
98
116
  positive = []
99
117
  negative = []
100
118
 
101
- Array(args).each do |arg|
102
- arg.split.each do |token|
103
- downcased = token.downcase
104
- if downcased =~ /\A[-~!]/
105
- negative << downcased[1..]
106
- elsif downcased.start_with?('+')
107
- positive << downcased[1..]
108
- else
109
- positive << downcased
110
- end
119
+ args.flat_map { |a| a.to_s.split }.each do |raw|
120
+ if raw.start_with?('+')
121
+ term = raw[1..].downcase
122
+ positive << term unless term.empty?
123
+ elsif raw.start_with?('-', '~', '!')
124
+ term = raw[1..].downcase
125
+ negative << term unless term.empty?
126
+ else
127
+ positive << raw.downcase
111
128
  end
112
129
  end
113
130
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'faraday'
4
4
  require 'word_wrapper'
5
- require 'set'
6
5
  require_relative 'directive'
7
6
  require_relative 'directives/configuration_directives'
8
7
  require_relative 'directives/context_directives'
@@ -10,6 +9,7 @@ require_relative 'directives/execution_directives'
10
9
  require_relative 'directives/utility_directives'
11
10
  require_relative 'directives/web_and_file_directives'
12
11
  require_relative 'directives/model_directives'
12
+ require_relative 'directives/trakflow_directives'
13
13
 
14
14
  module AIA
15
15
  class DirectiveProcessor
@@ -19,7 +19,6 @@ module AIA
19
19
  @prefix_size = DIRECTIVE_PREFIX.size
20
20
  end
21
21
 
22
-
23
22
  # Checks whether a string looks like a chat-time directive.
24
23
  # Uses PM.directives as the source of truth for known directive names.
25
24
  def directive?(string)
@@ -30,12 +29,21 @@ module AIA
30
29
 
31
30
  # Extract the directive name and check it's registered
32
31
  sans_prefix = stripped[@prefix_size..]
33
- method_name = sans_prefix.split(' ').first&.downcase
32
+ method_name = sans_prefix.split.first&.downcase
34
33
  return false if method_name.nil? || method_name.empty?
35
34
 
36
35
  PM.directives.key?(method_name.to_sym)
37
36
  end
38
37
 
38
+ # Returns true when the directive mutates turn-state or sets a mode flag
39
+ # rather than returning text for the AI. ChatLoop uses this to decide
40
+ # whether to skip forwarding the directive output to the robot.
41
+ def state_setting?(input)
42
+ name = extract_content(input).strip.split.first.to_s.delete_prefix('/')
43
+ AIA::Directive.subclasses.any? do |klass|
44
+ klass.state_setting_methods.include?(name)
45
+ end
46
+ end
39
47
 
40
48
  # Process a chat-time directive by dispatching through PM.directives.
41
49
  # Returns the block's return value: non-blank string for content directives,
@@ -46,7 +54,7 @@ module AIA
46
54
  content = extract_content(string)
47
55
  key = content.strip
48
56
  sans_prefix = key[@prefix_size..]
49
- args = sans_prefix.split(' ')
57
+ args = sans_prefix.split
50
58
  method_name = args.shift.downcase
51
59
 
52
60
  block = PM.directives[method_name.to_sym]
@@ -58,7 +66,6 @@ module AIA
58
66
  block.call(render_context, *args)
59
67
  end
60
68
 
61
-
62
69
  private
63
70
 
64
71
  def render_context
@@ -67,17 +74,19 @@ module AIA
67
74
  params: {},
68
75
  included: Set.new,
69
76
  depth: 0,
70
- metadata: OpenStruct.new(includes: [])
77
+ metadata: Struct.new(:includes).new([])
71
78
  )
72
79
  end
73
80
 
74
81
  def extract_content(string)
75
- if string.is_a?(RubyLLM::Message)
82
+ if defined?(RubyLLM::Message) && string.is_a?(RubyLLM::Message)
76
83
  begin
77
84
  string.content
78
85
  rescue StandardError
79
86
  string.to_s
80
87
  end
88
+ elsif string.respond_to?(:content)
89
+ string.content.to_s
81
90
  else
82
91
  string.to_s
83
92
  end