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.
- checksums.yaml +4 -4
- data/.envrc +9 -1
- data/.loki +11 -0
- data/.reek.yml +160 -0
- data/.rubocop.yml +116 -0
- data/.rubocop_strict.yml +15 -0
- data/.version +1 -1
- data/CHANGELOG.md +269 -55
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +267 -239
- data/Rakefile +5 -5
- data/_typos.toml +10 -0
- data/aia.gemspec +92 -0
- data/architecture_review.md +314 -0
- data/bin/aia +16 -0
- data/config/aia.yml +13 -0
- data/docs/AGENTS.md +40 -0
- data/docs/advanced-prompting.md +67 -3
- data/docs/cli-reference.md +312 -56
- data/docs/configuration.md +130 -19
- data/docs/contributing.md +56 -2
- data/docs/directives-reference.md +593 -78
- data/docs/faq.md +85 -3
- data/docs/guides/available-models.md +1 -1
- data/docs/guides/basic-usage.md +6 -6
- data/docs/guides/chat.md +40 -16
- data/docs/guides/crew.md +239 -0
- data/docs/guides/executable-prompts.md +1 -1
- data/docs/guides/index.md +1 -0
- data/docs/guides/models.md +15 -0
- data/docs/index.md +29 -2
- data/docs/installation.md +44 -17
- data/docs/mcp-integration.md +40 -0
- data/docs/prompt_management.md +85 -86
- data/docs/security.md +47 -0
- data/docs/special_projects_guide.md +386 -0
- data/docs/tools-and-mcp-examples.md +23 -0
- data/docs/workflows-and-pipelines.md +84 -7
- data/examples/.gitignore +1 -0
- data/examples/00_setup_aia.sh +27 -44
- data/examples/11_multi_model.sh +4 -14
- data/examples/12_token_usage.sh +3 -12
- data/examples/18_tools.sh +10 -2
- data/examples/22_chat_mode.sh +0 -10
- data/examples/23_verify.sh +139 -0
- data/examples/24_decompose.sh +139 -0
- data/examples/25_spawn.sh +139 -0
- data/examples/26_debate.sh +97 -0
- data/examples/27_mention_routing.sh +157 -0
- data/examples/28_model_switching.sh +106 -0
- data/examples/29_agent_harness.sh +177 -0
- data/examples/README.md +65 -0
- data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
- data/examples/aia_config.yml +1 -1
- data/examples/aia_config_orchestrator.yml +45 -0
- data/examples/common.sh +18 -6
- data/examples/context/tech_stack.md +2 -2
- data/examples/prompts_dir/roles/orchestrator.md +21 -0
- data/examples/requirements/sinatra_taskflow_app.md +139 -0
- data/examples/rules/01_classify_ruby.rb +16 -0
- data/examples/rules/02_prefer_claude_for_code.rb +19 -0
- data/examples/rules/03_gate_prompt_length.rb +19 -0
- data/examples/rules/04_tool_selection.rb +41 -0
- data/examples/rules/README.md +30 -0
- data/examples/run_all.sh +48 -15
- data/examples/tools/word_count_tool.rb +1 -1
- data/lib/AGENTS.md +57 -0
- data/lib/aia/chat_loop.rb +263 -167
- data/lib/aia/config/cli_parser.rb +217 -145
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +52 -51
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +171 -216
- data/lib/aia/config.rb +111 -145
- data/lib/aia/content_extractor.rb +155 -0
- data/lib/aia/cost_calculator.rb +39 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +174 -0
- data/lib/aia/delegate_handler.rb +116 -0
- data/lib/aia/directive.rb +43 -26
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +214 -60
- data/lib/aia/directives/context_directives.rb +67 -52
- data/lib/aia/directives/execution_directives.rb +141 -4
- data/lib/aia/directives/model_directives.rb +163 -141
- data/lib/aia/directives/trakflow_directives.rb +62 -0
- data/lib/aia/directives/utility_directives.rb +227 -30
- data/lib/aia/directives/web_and_file_directives.rb +126 -77
- data/lib/aia/errors.rb +15 -0
- data/lib/aia/fact_asserter.rb +27 -0
- data/lib/aia/fzf.rb +9 -31
- data/lib/aia/handler_context.rb +17 -0
- data/lib/aia/handler_protocol.rb +19 -0
- data/lib/aia/history_transfer.rb +55 -0
- data/lib/aia/input_collector.rb +3 -3
- data/lib/aia/layered_orchestrator.rb +471 -0
- data/lib/aia/logger.rb +45 -25
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +315 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_server_config.rb +30 -0
- data/lib/aia/mcp_utility.rb +60 -0
- data/lib/aia/mention_router.rb +217 -0
- data/lib/aia/model_alias_registry.rb +97 -0
- data/lib/aia/model_switch_handler.rb +100 -0
- data/lib/aia/network_builder.rb +160 -0
- data/lib/aia/network_memory_manager.rb +55 -0
- data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
- data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
- data/lib/aia/pipeline_orchestrator.rb +272 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +211 -0
- data/lib/aia/prompt_decomposer.rb +159 -0
- data/lib/aia/prompt_handler.rb +56 -82
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +338 -0
- data/lib/aia/robot_namer.rb +110 -0
- data/lib/aia/session.rb +87 -17
- data/lib/aia/session_tracker.rb +207 -0
- data/lib/aia/similarity_scorer.rb +41 -0
- data/lib/aia/skill_utils.rb +105 -1
- data/lib/aia/spawn_handler.rb +129 -0
- data/lib/aia/spawn_spec_parser.rb +65 -0
- data/lib/aia/special_mode_handler.rb +322 -0
- data/lib/aia/speech.rb +67 -0
- data/lib/aia/startup_coordinator.rb +151 -0
- data/lib/aia/streaming_runner.rb +172 -0
- data/lib/aia/system_prompt_assembler.rb +92 -0
- data/lib/aia/task_coordinator.rb +207 -0
- data/lib/aia/task_decomposer.rb +57 -0
- data/lib/aia/task_executor.rb +51 -0
- data/lib/aia/tfidf_math.rb +27 -0
- data/lib/aia/timing.rb +15 -0
- data/lib/aia/tool_filter/tfidf.rb +116 -0
- data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
- data/lib/aia/tool_filter.rb +83 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +146 -0
- data/lib/aia/tool_introspection.rb +17 -0
- data/lib/aia/tool_loader.rb +216 -0
- data/lib/aia/tool_utility.rb +30 -0
- data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
- data/lib/aia/tools/recruit_robot_tool.rb +60 -0
- data/lib/aia/tools/reskill_robot_tool.rb +44 -0
- data/lib/aia/tools/task_board_tool.rb +115 -0
- data/lib/aia/trakflow_bridge.rb +175 -0
- data/lib/aia/turn_state.rb +95 -0
- data/lib/aia/ui_presenter.rb +182 -206
- data/lib/aia/utility.rb +136 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
- data/lib/aia/verification_network.rb +57 -0
- data/lib/aia.rb +124 -63
- data/mkdocs.yml +1 -0
- metadata +187 -58
- data/justfile +0 -215
- data/lib/aia/adapter/chat_execution.rb +0 -242
- data/lib/aia/adapter/error_handler.rb +0 -68
- data/lib/aia/adapter/gem_activator.rb +0 -57
- data/lib/aia/adapter/mcp_connector.rb +0 -274
- data/lib/aia/adapter/modality_handlers.rb +0 -167
- data/lib/aia/adapter/model_registry.rb +0 -81
- data/lib/aia/adapter/multi_model_chat.rb +0 -218
- data/lib/aia/adapter/provider_configurator.rb +0 -59
- data/lib/aia/adapter/tool_filter.rb +0 -85
- data/lib/aia/adapter/tool_loader.rb +0 -90
- data/lib/aia/chat_processor_service.rb +0 -178
- data/lib/aia/prompt_pipeline.rb +0 -183
- data/lib/aia/ruby_llm_adapter.rb +0 -95
- data/lib/extensions/openstruct_merge.rb +0 -48
- data/lib/extensions/ruby_llm/.irbrc +0 -56
- data/lib/extensions/ruby_llm/modalities.rb +0 -36
- data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
- data/lib/refinements/string.rb +0 -16
- data/main.just +0 -76
|
@@ -0,0 +1,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
|