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
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/streaming_runner.rb
4
+ #
5
+ # Encapsulates the streaming execution pattern used across ChatLoop,
6
+ # MentionRouter, and expert routing. Manages spinner lifecycle and
7
+ # streaming callback for any robot.
8
+
9
+ require "tty-spinner"
10
+
11
+ module AIA
12
+ class StreamingRunner
13
+ # Hard ceiling on tools sent to the provider. OpenAI and Anthropic both
14
+ # reject tool arrays longer than 128. Overridable via config.max_tools.
15
+ DEFAULT_MAX_TOOLS = 128
16
+
17
+ def initialize
18
+ @spinner = TTY::Spinner.new("[:spinner] Processing...", format: :bouncing_ball)
19
+ end
20
+
21
+ # Run a robot with streaming output.
22
+ # Spinner shows until the first chunk arrives, then stops and
23
+ # chunks are printed directly to stdout.
24
+ #
25
+ # @param robot [RobotLab::Robot, RobotLab::Network] the robot to run
26
+ # @param prompt [String] the prompt to send
27
+ # @param header [String] text printed before the first streamed chunk
28
+ # @param spinner_message [String] spinner label
29
+ # @param tools [Array<String>, nil] tool names to allow for this turn (nil = all)
30
+ # @return [Array(Object, String, Float)] [result, streamed_content_or_nil, elapsed_seconds]
31
+ # :reek:TooManyStatements -- one streaming turn: spinner lifecycle, chunk-filter closure, robot run with cleanup, timing
32
+ # :reek:DuplicateMethodCall -- @spinner.stop guards three distinct control paths (first chunk, exception, completion)
33
+ def run(robot, prompt, header: "\nAI:\n ", spinner_message: "Processing...", tools: nil)
34
+ @spinner.reset
35
+ @spinner.update(title: spinner_message)
36
+ @spinner.auto_spin
37
+ streamed = []
38
+ header_printed = false
39
+ in_think_block = false # tracks position inside a <think>...</think> span across chunks
40
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+
42
+ streaming_block = proc do |chunk|
43
+ text = chunk.respond_to?(:content) ? chunk.content.to_s : chunk.to_s
44
+ next if text.empty?
45
+
46
+ # qwen3 and similar local reasoning models embed thinking inline in the
47
+ # content stream wrapped in <think>...</think> tags. Filter them out
48
+ # unless --thinking is on. Tag boundaries may split across chunks, so
49
+ # in_think_block persists across calls via the closure.
50
+ unless show_thinking?
51
+ text, in_think_block = filter_thinking(text, in_think_block)
52
+ next if text.empty?
53
+ end
54
+
55
+ unless header_printed
56
+ @spinner.stop
57
+ print header
58
+ header_printed = true
59
+ end
60
+
61
+ streamed << text
62
+ $stdout.print(text)
63
+ end
64
+
65
+ tools_param = resolve_tools_param(tools, robot)
66
+
67
+ begin
68
+ result = robot.run(prompt, mcp: :inherit, tools: tools_param, &streaming_block)
69
+ rescue Exception # rubocop:disable Lint/RescueException
70
+ @spinner.stop unless header_printed
71
+ raise
72
+ end
73
+
74
+ @spinner.stop unless header_printed
75
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
76
+ content = streamed.empty? ? nil : streamed.join
77
+ [result, content, elapsed]
78
+ end
79
+
80
+ private
81
+
82
+ # Translate the resolved tool list into robot_lab's ToolConfig vocabulary,
83
+ # then clamp it to the provider's limit:
84
+ # nil -> :inherit (no filter active / filter errored — use the full set)
85
+ # [] -> :none (filter ran and found nothing relevant — send no tools)
86
+ # names -> names (filtered subset; ToolConfig.filter_tools applies them)
87
+ # Distinguishing [] from nil is what lets a "nothing relevant" turn send zero
88
+ # tools instead of the whole build-time set.
89
+ def resolve_tools_param(tools, robot)
90
+ param =
91
+ if tools.nil?
92
+ :inherit
93
+ elsif tools.empty?
94
+ :none
95
+ else
96
+ tools
97
+ end
98
+
99
+ enforce_tool_cap(param, robot)
100
+ end
101
+
102
+ # Clamp the effective tool list to the provider's maximum. Returns the
103
+ # value unchanged when it's already within budget (including :none, and
104
+ # :inherit when the robot's own tool count is under the cap). When over,
105
+ # expands :inherit to the robot's tool names, trims to the cap, and warns —
106
+ # a reduced tool set beats a failed turn.
107
+ def enforce_tool_cap(tools_param, robot)
108
+ return tools_param if tools_param == :none
109
+
110
+ max = max_tools
111
+ names = tools_param == :inherit ? robot_tool_names(robot) : tools_param
112
+ size = names.size
113
+ return tools_param if size <= max
114
+
115
+ dropped = size - max
116
+ $stderr.puts(
117
+ "⚠ Tool list (#{size}) exceeds the provider limit of #{max}; " \
118
+ "sending #{max}, dropping #{dropped}. Enable --auto-tool-filter or reduce " \
119
+ "the available tools to control which ones are sent."
120
+ )
121
+ names.first(max)
122
+ end
123
+
124
+ # The provider tool cap, from config.max_tools when set, else the default.
125
+ def max_tools
126
+ configured = AIA.respond_to?(:config) && AIA.config.respond_to?(:max_tools) ? AIA.config.max_tools : nil
127
+ configured&.positive? ? configured : DEFAULT_MAX_TOOLS
128
+ end
129
+
130
+ # The robot's full set of tool names (local + MCP). Used only to size and
131
+ # trim an over-limit :inherit set.
132
+ def robot_tool_names(robot)
133
+ local = robot.respond_to?(:local_tools) ? Array(robot.local_tools) : []
134
+ mcp = robot.respond_to?(:mcp_tools) ? Array(robot.mcp_tools) : []
135
+ (local + mcp).map { |t| t.respond_to?(:name) ? t.name : t.class.name }
136
+ end
137
+
138
+ def show_thinking?
139
+ AIA.config&.flags&.thinking
140
+ end
141
+
142
+ # Strip <think>...</think> spans from a streaming chunk, handling the case
143
+ # where tag boundaries fall between chunk deliveries.
144
+ #
145
+ # @param text [String] raw chunk text
146
+ # @param in_think_block [Boolean] whether a <think> tag is currently open
147
+ # @return [Array(String, Boolean)] [filtered_text, updated_in_think_block]
148
+ def filter_thinking(text, in_think_block)
149
+ output = +''
150
+
151
+ until text.empty?
152
+ if in_think_block
153
+ if (idx = text.index('</think>'))
154
+ in_think_block = false
155
+ text = text[(idx + '</think>'.length)..]
156
+ else
157
+ break # entire remaining chunk is thinking — discard
158
+ end
159
+ elsif (idx = text.index('<think>'))
160
+ output << text[0...idx]
161
+ in_think_block = true
162
+ text = text[(idx + '<think>'.length)..]
163
+ else
164
+ output << text
165
+ break
166
+ end
167
+ end
168
+
169
+ [output, in_think_block]
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,92 @@
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
+ prompts_cfg = config.prompts
22
+ system_prompt = prompts_cfg.system_prompt
23
+
24
+ role_id = model_spec&.role || prompts_cfg.role
25
+ if role_id && !role_id.empty?
26
+ role_content = load_role_content(config, role_id)
27
+ if role_content
28
+ system_prompt = [system_prompt, role_content].compact.join("\n\n")
29
+ end
30
+ end
31
+
32
+ # In chat mode inject --skill content here so it persists across all turns.
33
+ # Pipeline mode appends skills to each prompt text instead (pipeline_orchestrator).
34
+ if config.flags&.chat == true
35
+ skill_content = AIA::SkillUtils.load_skills_content(
36
+ Array(prompts_cfg&.skills),
37
+ AIA::SkillUtils.skills_base_dir(config)
38
+ )
39
+ system_prompt = [system_prompt, skill_content].compact.join("\n\n") if skill_content
40
+ end
41
+
42
+ system_prompt
43
+ end
44
+
45
+ # Build a system prompt fragment that tells a robot its name, its
46
+ # model, and the other robots in the network.
47
+ #
48
+ # @param robot_name [String] this robot's creative name
49
+ # @param spec [ModelSpec] this robot's model spec
50
+ # @param roster [Array<Hash>] all robots: [{ name:, spec: }, ...]
51
+ # @return [String]
52
+ # :reek:TooManyStatements -- builds the identity line plus a roster entry per teammate; one cohesive prompt fragment
53
+ def build_identity_prompt(robot_name, spec, roster)
54
+ provider_label = spec.provider ? " (#{spec.provider})" : ""
55
+ lines = ["You are #{robot_name}, powered by #{spec.name}#{provider_label}."]
56
+
57
+ if roster.size > 1
58
+ lines << "You are part of a team of AI robots:"
59
+ roster.each do |entry|
60
+ entry_spec = entry[:spec]
61
+ p = entry_spec.provider ? " (#{entry_spec.provider})" : ""
62
+ marker = entry[:name] == robot_name ? " ← you" : ""
63
+ lines << " - #{entry[:name]}: #{entry_spec.name}#{p}#{marker}"
64
+ end
65
+ lines << "Users can address a specific robot with @name mentions."
66
+ end
67
+
68
+ lines.join("\n")
69
+ end
70
+
71
+ # Load role file content.
72
+ #
73
+ # @param config [AIA::Config] the AIA configuration
74
+ # @param role_id [String] the role identifier
75
+ # @return [String, nil] the role file content or nil
76
+ def load_role_content(config, role_id)
77
+ prompts_cfg = config.prompts
78
+ roles_prefix = prompts_cfg.roles_prefix
79
+ unless role_id.start_with?(roles_prefix)
80
+ role_id = "#{roles_prefix}/#{role_id}"
81
+ end
82
+
83
+ role_file = File.join(prompts_cfg.dir, "#{role_id}#{prompts_cfg.extname}")
84
+ return nil unless File.exist?(role_file)
85
+
86
+ File.read(role_file)
87
+ rescue => e
88
+ $stderr.puts "Warning: Could not load role '#{role_id}': #{e.message}"
89
+ nil
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,207 @@
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
+ # :reek:LongParameterList { max_params: 6 } -- one keyword per optional task attribute; callers set only what they need
37
+ def create_task(title, assignee: nil, parent_id: nil,
38
+ blocked_by: [], labels: [], creator: "aia")
39
+ return nil unless available?
40
+
41
+ task = TrakFlow::Models::Task.new(
42
+ title: title, assignee: assignee, type: "task"
43
+ )
44
+ task = @db.create_task(task)
45
+ task_id = task.id
46
+
47
+ @db.add_label(TrakFlow::Models::Label.new(
48
+ task_id: task_id, name: "creator:#{creator}"
49
+ ))
50
+
51
+ labels.each do |label|
52
+ @db.add_label(TrakFlow::Models::Label.new(
53
+ task_id: task_id, name: label
54
+ ))
55
+ end
56
+
57
+ blocked_by.each do |blocker_id|
58
+ @db.add_dependency(TrakFlow::Models::Dependency.new(
59
+ source_id: blocker_id, target_id: task_id, type: "blocks"
60
+ ))
61
+ end
62
+
63
+ if parent_id
64
+ @db.add_dependency(TrakFlow::Models::Dependency.new(
65
+ source_id: parent_id, target_id: task_id, type: "parent-child"
66
+ ))
67
+ end
68
+
69
+ task
70
+ end
71
+
72
+ # A robot creates a full plan (blueprint) with ordered steps.
73
+ #
74
+ # @param title [String] plan title
75
+ # @param steps [Array<Hash>] each: { title:, assignee:, labels: [] }
76
+ # @param creator [String] creating robot name
77
+ # @param ephemeral [Boolean] true for single-session plans (auto-gc)
78
+ # @return [Hash, nil] { plan:, steps: }
79
+ # :reek:BooleanParameter -- ephemeral: tags a plan for auto-gc at session end; a second method would duplicate the whole build
80
+ # :reek:TooManyStatements -- plan build: create the plan task, then per step a child task, labels, and ordering dependency
81
+ def create_plan(title, steps:, creator: "aia", ephemeral: false)
82
+ return nil unless available?
83
+
84
+ plan = TrakFlow::Models::Task.new(
85
+ title: title, plan: true, type: "task"
86
+ )
87
+ plan = @db.create_task(plan)
88
+
89
+ @db.add_label(TrakFlow::Models::Label.new(
90
+ task_id: plan.id, name: "creator:#{creator}"
91
+ ))
92
+
93
+ prev_step = nil
94
+ step_tasks = steps.map.with_index do |step_def, _i|
95
+ step = @db.create_child_task(plan.id, {
96
+ title: step_def[:title],
97
+ assignee: step_def[:assignee],
98
+ type: "task"
99
+ })
100
+
101
+ Array(step_def[:labels]).each do |label|
102
+ @db.add_label(TrakFlow::Models::Label.new(
103
+ task_id: step.id, name: label
104
+ ))
105
+ end
106
+
107
+ if prev_step
108
+ @db.add_dependency(TrakFlow::Models::Dependency.new(
109
+ source_id: prev_step.id, target_id: step.id, type: "blocks"
110
+ ))
111
+ end
112
+
113
+ prev_step = step
114
+ step
115
+ end
116
+
117
+ { plan: plan, steps: step_tasks }
118
+ end
119
+
120
+ # Get ready tasks, optionally filtered by assignee.
121
+ #
122
+ # @param robot_name [String, nil] filter by assignee
123
+ # @return [Array<TrakFlow::Models::Task>]
124
+ def ready_tasks(robot_name: nil)
125
+ return [] unless available?
126
+
127
+ tasks = @db.ready_tasks
128
+ robot_name ? tasks.select { |t| t.assignee == robot_name } : tasks
129
+ end
130
+
131
+ # A robot claims a task (sets in_progress with its name).
132
+ #
133
+ # @param task_id [String]
134
+ # @param robot_name [String]
135
+ def claim_task(task_id, robot_name)
136
+ return unless available?
137
+
138
+ task = @db.find_task(task_id)
139
+ return unless task
140
+
141
+ task.status = "in_progress"
142
+ task.assignee = robot_name
143
+ task.append_trace("claimed", "Claimed by #{robot_name}")
144
+ @db.update_task(task)
145
+ end
146
+
147
+ # A robot completes a task with a result summary.
148
+ #
149
+ # @param task_id [String]
150
+ # @param result [String] summary of what was done
151
+ # @param robot_name [String]
152
+ def complete_task(task_id, result:, robot_name:)
153
+ return unless available?
154
+
155
+ task = @db.find_task(task_id)
156
+ return unless task
157
+
158
+ task.close!(reason: result)
159
+ @db.add_comment(TrakFlow::Models::Comment.new(
160
+ task_id: task_id, author: robot_name, body: result
161
+ ))
162
+ @db.update_task(task)
163
+ end
164
+
165
+ # A robot marks a task as blocked with a reason.
166
+ #
167
+ # @param task_id [String]
168
+ # @param reason [String]
169
+ # @param robot_name [String]
170
+ def block_task(task_id, reason:, robot_name:)
171
+ return unless available?
172
+
173
+ task = @db.find_task(task_id)
174
+ return unless task
175
+
176
+ task.status = "blocked"
177
+ task.append_trace("blocked", "#{robot_name}: #{reason}")
178
+ @db.update_task(task)
179
+ end
180
+
181
+ # Summary of the current task board state.
182
+ #
183
+ # @return [String, nil]
184
+ # :reek:TooManyStatements -- one board report: totals line, per-assignee open/done counts, unassigned tail
185
+ def status_summary
186
+ return nil unless available?
187
+
188
+ all = @db.list_tasks({})
189
+ ready = @db.ready_tasks
190
+ blocked = @db.blocked_tasks
191
+
192
+ by_assignee = all.select(&:assignee).group_by(&:assignee)
193
+
194
+ lines = ["Task Board (#{all.size} total, #{ready.size} ready, #{blocked.size} blocked):"]
195
+ by_assignee.sort.each do |assignee, tasks|
196
+ open_count = tasks.count { |t| t.open? || t.in_progress? }
197
+ closed_count = tasks.count(&:closed?)
198
+ lines << " #{assignee}: #{open_count} open, #{closed_count} done"
199
+ end
200
+
201
+ unassigned = all.reject(&:assignee)
202
+ lines << " unassigned: #{unassigned.size}" unless unassigned.empty?
203
+
204
+ lines.join("\n")
205
+ end
206
+ end
207
+ 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
data/lib/aia/timing.rb ADDED
@@ -0,0 +1,15 @@
1
+ # lib/aia/timing.rb
2
+
3
+ module AIA
4
+ # Monotonic-clock instrumentation shared by benchmark-reporting code.
5
+ module Timing
6
+ module_function
7
+
8
+ # Run the block and return [result, elapsed_seconds].
9
+ def timed
10
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ result = yield
12
+ [result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0]
13
+ end
14
+ end
15
+ end