aia 1.1.1 → 2.0.0.0.pre.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) 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 +259 -50
  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 +18 -6
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  57. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  58. data/examples/rules/01_classify_ruby.rb +16 -0
  59. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  60. data/examples/rules/03_gate_prompt_length.rb +19 -0
  61. data/examples/rules/04_tool_selection.rb +41 -0
  62. data/examples/rules/README.md +30 -0
  63. data/examples/run_all.sh +48 -15
  64. data/examples/tools/word_count_tool.rb +1 -1
  65. data/lib/AGENTS.md +57 -0
  66. data/lib/aia/chat_loop.rb +304 -164
  67. data/lib/aia/config/cli_parser.rb +174 -111
  68. data/lib/aia/config/defaults.yml +62 -33
  69. data/lib/aia/config/mcp_parser.rb +39 -46
  70. data/lib/aia/config/model_spec.rb +34 -2
  71. data/lib/aia/config/validator.rb +108 -142
  72. data/lib/aia/config.rb +110 -145
  73. data/lib/aia/content_extractor.rb +153 -0
  74. data/lib/aia/cost_calculator.rb +38 -0
  75. data/lib/aia/crew.rb +164 -0
  76. data/lib/aia/debate_handler.rb +166 -0
  77. data/lib/aia/delegate_handler.rb +112 -0
  78. data/lib/aia/directive.rb +33 -18
  79. data/lib/aia/directive_processor.rb +16 -7
  80. data/lib/aia/directives/configuration_directives.rb +160 -20
  81. data/lib/aia/directives/context_directives.rb +38 -26
  82. data/lib/aia/directives/execution_directives.rb +136 -4
  83. data/lib/aia/directives/model_directives.rb +76 -34
  84. data/lib/aia/directives/trakflow_directives.rb +44 -0
  85. data/lib/aia/directives/utility_directives.rb +203 -6
  86. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  87. data/lib/aia/errors.rb +15 -0
  88. data/lib/aia/fact_asserter.rb +27 -0
  89. data/lib/aia/fzf.rb +9 -31
  90. data/lib/aia/handler_context.rb +17 -0
  91. data/lib/aia/handler_protocol.rb +19 -0
  92. data/lib/aia/history_transfer.rb +55 -0
  93. data/lib/aia/input_collector.rb +3 -3
  94. data/lib/aia/layered_orchestrator.rb +448 -0
  95. data/lib/aia/logger.rb +24 -4
  96. data/lib/aia/mcp_config_normalizer.rb +35 -0
  97. data/lib/aia/mcp_connection_manager.rb +305 -0
  98. data/lib/aia/mcp_discovery.rb +44 -0
  99. data/lib/aia/mcp_grouper.rb +33 -0
  100. data/lib/aia/mcp_utility.rb +57 -0
  101. data/lib/aia/mention_router.rb +260 -0
  102. data/lib/aia/model_alias_registry.rb +97 -0
  103. data/lib/aia/model_switch_handler.rb +100 -0
  104. data/lib/aia/network_builder.rb +155 -0
  105. data/lib/aia/network_memory_manager.rb +55 -0
  106. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  107. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  108. data/lib/aia/pipeline_orchestrator.rb +262 -0
  109. data/lib/aia/plugin_loader.rb +170 -0
  110. data/lib/aia/plugin_monitor.rb +208 -0
  111. data/lib/aia/prompt_decomposer.rb +157 -0
  112. data/lib/aia/prompt_handler.rb +19 -39
  113. data/lib/aia/robot_builder.rb +51 -0
  114. data/lib/aia/robot_factory.rb +334 -0
  115. data/lib/aia/robot_namer.rb +116 -0
  116. data/lib/aia/session.rb +83 -17
  117. data/lib/aia/session_tracker.rb +209 -0
  118. data/lib/aia/similarity_scorer.rb +39 -0
  119. data/lib/aia/skill_utils.rb +105 -1
  120. data/lib/aia/spawn_handler.rb +129 -0
  121. data/lib/aia/spawn_spec_parser.rb +65 -0
  122. data/lib/aia/special_mode_handler.rb +302 -0
  123. data/lib/aia/startup_coordinator.rb +150 -0
  124. data/lib/aia/streaming_runner.rb +169 -0
  125. data/lib/aia/system_prompt_assembler.rb +88 -0
  126. data/lib/aia/task_coordinator.rb +202 -0
  127. data/lib/aia/task_decomposer.rb +57 -0
  128. data/lib/aia/task_executor.rb +51 -0
  129. data/lib/aia/tfidf_math.rb +27 -0
  130. data/lib/aia/tool_filter/tfidf.rb +116 -0
  131. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  132. data/lib/aia/tool_filter.rb +82 -0
  133. data/lib/aia/tool_filter_registry.rb +30 -0
  134. data/lib/aia/tool_filter_strategy.rb +143 -0
  135. data/lib/aia/tool_loader.rb +210 -0
  136. data/lib/aia/tool_utility.rb +30 -0
  137. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  138. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  139. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  140. data/lib/aia/tools/task_board_tool.rb +114 -0
  141. data/lib/aia/trakflow_bridge.rb +173 -0
  142. data/lib/aia/turn_state.rb +94 -0
  143. data/lib/aia/ui_presenter.rb +166 -198
  144. data/lib/aia/utility.rb +134 -87
  145. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  146. data/lib/aia/verification_network.rb +58 -0
  147. data/lib/aia.rb +108 -63
  148. data/mkdocs.yml +1 -0
  149. metadata +179 -56
  150. data/justfile +0 -215
  151. data/lib/aia/adapter/chat_execution.rb +0 -242
  152. data/lib/aia/adapter/error_handler.rb +0 -68
  153. data/lib/aia/adapter/gem_activator.rb +0 -57
  154. data/lib/aia/adapter/mcp_connector.rb +0 -274
  155. data/lib/aia/adapter/modality_handlers.rb +0 -167
  156. data/lib/aia/adapter/model_registry.rb +0 -81
  157. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  158. data/lib/aia/adapter/provider_configurator.rb +0 -59
  159. data/lib/aia/adapter/tool_filter.rb +0 -85
  160. data/lib/aia/adapter/tool_loader.rb +0 -90
  161. data/lib/aia/chat_processor_service.rb +0 -178
  162. data/lib/aia/prompt_pipeline.rb +0 -183
  163. data/lib/aia/ruby_llm_adapter.rb +0 -95
  164. data/lib/extensions/openstruct_merge.rb +0 -48
  165. data/lib/extensions/ruby_llm/.irbrc +0 -56
  166. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  167. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  168. data/lib/refinements/string.rb +0 -16
  169. 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,166 @@
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
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
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_context = build_round_context(prompt, rounds)
59
+
60
+ round_results = Sync do
61
+ barrier = Async::Barrier.new
62
+ tasks = robots.map do |robot|
63
+ barrier.async do
64
+ @ui_presenter.display_info(" Round #{round + 1}: #{robot.name}...")
65
+ result = robot.run(round_context, mcp: :inherit, tools: :inherit)
66
+ content = extract_content(result)
67
+ write_to_memory(round, robot.name, content)
68
+ { robot: robot.name, content: content }
69
+ rescue => e
70
+ FailedResponse.new(robot_name: robot.name, error_message: e.message)
71
+ end
72
+ end
73
+ barrier.wait
74
+ tasks.map(&:wait)
75
+ end
76
+
77
+ raise DebateError, "All robots failed in round #{round + 1}" if
78
+ round_results.all?(FailedResponse)
79
+
80
+ previous = rounds.last
81
+ rounds << round_results
82
+
83
+ if converged?(round, round_results, previous)
84
+ @ui_presenter.display_info(" Converged in round #{round + 1}.")
85
+ break
86
+ end
87
+ end
88
+
89
+ @tracker.record_turn(
90
+ model: AIA.config.models.first.name,
91
+ input: prompt,
92
+ result: rounds
93
+ )
94
+
95
+ format_rounds(rounds)
96
+ end
97
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
98
+
99
+ private
100
+
101
+ def build_round_context(prompt, rounds)
102
+ return prompt if rounds.empty?
103
+
104
+ previous = rounds.last.map do |r|
105
+ if r.is_a?(FailedResponse)
106
+ "#{r.robot_name}: [FAILED: #{r.error_message}]"
107
+ else
108
+ "#{r[:robot]}: #{r[:content]}"
109
+ end
110
+ end.join("\n\n")
111
+
112
+ <<~CONTEXT
113
+ Topic: #{prompt}
114
+
115
+ Previous round:
116
+ #{previous}
117
+
118
+ Respond to the points above. Say CONVERGED if you agree with the overall consensus and provide a final summary. Otherwise refine your position.
119
+ CONTEXT
120
+ end
121
+
122
+ def converged?(round_index, current_results, previous_results)
123
+ return false if round_index < MIN_ROUNDS - 1
124
+
125
+ # Fast path: every robot explicitly signaled convergence.
126
+ return true if all_signaled_convergence?(current_results)
127
+
128
+ return false if previous_results.nil?
129
+
130
+ current_text = current_results.map { |r| r.is_a?(FailedResponse) ? "" : r[:content].to_s }.join(" ")
131
+ previous_text = previous_results.map { |r| r.is_a?(FailedResponse) ? "" : r[:content].to_s }.join(" ")
132
+
133
+ scores = SimilarityScorer.score([previous_text, current_text])
134
+ (scores[1] || 0.0) >= SIMILARITY_THRESHOLD
135
+ end
136
+
137
+ def all_signaled_convergence?(round_results)
138
+ round_results.all? do |r|
139
+ next false if r.is_a?(FailedResponse)
140
+ r[:content].to_s.upcase.include?(CONVERGENCE_SIGNAL)
141
+ end
142
+ end
143
+
144
+ def write_to_memory(round, robot_name, content)
145
+ return unless @robot.respond_to?(:memory)
146
+
147
+ @robot.memory.current_writer = robot_name
148
+ @robot.memory.set(:"debate_r#{round}_#{robot_name}", content)
149
+ end
150
+
151
+ def format_rounds(rounds)
152
+ lines = []
153
+ rounds.each_with_index do |round, i|
154
+ lines << "### Round #{i + 1}"
155
+ round.each do |entry|
156
+ lines << if entry.is_a?(FailedResponse)
157
+ "**#{entry.robot_name}**: [FAILED] #{entry.error_message}\n"
158
+ else
159
+ "**#{entry[:robot]}**: #{entry[:content]}\n"
160
+ end
161
+ end
162
+ end
163
+ lines.join("\n")
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,112 @@
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
+ def handle(context)
31
+ prompt = context.prompt
32
+ return nil unless @robot.network?
33
+ return nil unless @task_coordinator&.available?
34
+
35
+ robots = @robot.robots
36
+ robot_names = robots.values.map(&:name)
37
+ lead = robots.values.first
38
+
39
+ # Step 1: Decompose the work
40
+ decomposer = TaskDecomposer.new(lead_robot: lead, ui_presenter: @ui_presenter)
41
+ steps = decomposer.decompose(prompt, robot_names)
42
+
43
+ if steps.empty?
44
+ @ui_presenter.display_info("Could not decompose into subtasks.")
45
+ return nil
46
+ end
47
+
48
+ # Step 2: Create TrakFlow plan
49
+ plan = @task_coordinator.create_plan(
50
+ "Delegated: #{prompt[0, 60]}",
51
+ steps: steps, creator: lead.name, ephemeral: true
52
+ )
53
+
54
+ @ui_presenter.display_info("Plan with #{steps.size} tasks:")
55
+ steps.each_with_index do |s, i|
56
+ @ui_presenter.display_info(" #{i + 1}. #{s[:title]} -> #{s[:assignee]}")
57
+ end
58
+
59
+ # Step 3: Execute tasks in order
60
+ executor = TaskExecutor.new(task_coordinator: @task_coordinator)
61
+ results = execute_steps(executor, prompt, plan, steps, robots)
62
+
63
+ @tracker.record_turn(
64
+ model: AIA.config.models.first.name,
65
+ input: prompt,
66
+ result: results
67
+ )
68
+
69
+ format_results(results)
70
+ end
71
+
72
+ private
73
+
74
+ def execute_steps(executor, prompt, plan, steps, robots)
75
+ results = []
76
+
77
+ plan[:steps].each_with_index do |task, i|
78
+ step_def = steps[i]
79
+ assignee = robots.values.find { |r| r.name == step_def[:assignee] }
80
+ assignee ||= robots.values.first
81
+
82
+ @ui_presenter.display_info(" #{assignee.name}: #{step_def[:title]}...")
83
+
84
+ content = executor.execute(task, assignee, step_def, prompt, results)
85
+
86
+ write_to_memory(i, assignee.name, step_def[:title], content)
87
+ results << { robot: assignee.name, task: step_def[:title], content: content }
88
+ end
89
+
90
+ results
91
+ end
92
+
93
+ def write_to_memory(index, robot_name, task_title, content)
94
+ return unless @robot.respond_to?(:memory)
95
+
96
+ @robot.memory.current_writer = robot_name
97
+ @robot.memory.set(:"delegate_step_#{index}", {
98
+ robot: robot_name, task: task_title, content: content
99
+ })
100
+ end
101
+
102
+ def format_results(results)
103
+ lines = []
104
+ results.each_with_index do |r, i|
105
+ lines << "### Step #{i + 1}: #{r[:task]} (#{r[:robot]})"
106
+ lines << r[:content]
107
+ lines << ""
108
+ end
109
+ lines.join("\n")
110
+ end
111
+ end
112
+ 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.
@@ -87,27 +106,23 @@ module AIA
87
106
 
88
107
  private
89
108
 
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]
109
+ # Split raw args into [positive_terms, negative_terms].
110
+ # Leading '+' is stripped; the term is treated as positive.
111
+ # Leading '-', '~', or '!' marks the term as negative (AND NOT).
112
+ # Bare terms are positive. All terms are lowercased.
97
113
  def parse_search_terms(args)
98
114
  positive = []
99
115
  negative = []
100
116
 
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
117
+ args.flat_map { |a| a.to_s.split }.each do |raw|
118
+ if raw.start_with?('+')
119
+ term = raw[1..].downcase
120
+ positive << term unless term.empty?
121
+ elsif raw.start_with?('-', '~', '!')
122
+ term = raw[1..].downcase
123
+ negative << term unless term.empty?
124
+ else
125
+ positive << raw.downcase
111
126
  end
112
127
  end
113
128
 
@@ -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