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,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/handler_protocol.rb
|
|
4
|
+
#
|
|
5
|
+
# Uniform interface for all turn-level handlers.
|
|
6
|
+
# Include this module and implement handle(context) where context
|
|
7
|
+
# is an AIA::HandlerContext value object.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
module HandlerProtocol
|
|
11
|
+
# Process a turn. Each handler reads only the context fields it needs.
|
|
12
|
+
#
|
|
13
|
+
# @param context [AIA::HandlerContext]
|
|
14
|
+
# @return handler-specific result (String content, Boolean, or nil)
|
|
15
|
+
def handle(context)
|
|
16
|
+
raise NotImplementedError, "#{self.class} must implement #handle(context)"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/history_transfer.rb
|
|
4
|
+
#
|
|
5
|
+
# Stateless module for transferring conversation history between robots.
|
|
6
|
+
# Extracted from RobotFactory to isolate the history transfer concern.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
module HistoryTransfer
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Replay conversation history from an old robot to a new one.
|
|
13
|
+
#
|
|
14
|
+
# Performance: O(N) API calls where N = number of user messages.
|
|
15
|
+
# Each user message triggers a full LLM round-trip on the new model.
|
|
16
|
+
# For a 10-turn conversation with a local model (~1s/turn), expect ~10s.
|
|
17
|
+
# For a cloud model (~2-5s/turn), expect 20-50s. MCP/tools are disabled
|
|
18
|
+
# during replay to avoid side effects.
|
|
19
|
+
def replay_history(old_robot, new_robot)
|
|
20
|
+
return unless old_robot.respond_to?(:messages)
|
|
21
|
+
|
|
22
|
+
old_robot.messages.each do |msg|
|
|
23
|
+
next unless msg.respond_to?(:role) && msg.role == :user
|
|
24
|
+
new_robot.run(msg.content, mcp: :none, tools: :none)
|
|
25
|
+
end
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
$stderr.puts "Warning: History replay failed: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Summarize conversation history and inject into new robot.
|
|
31
|
+
#
|
|
32
|
+
# Performance: Exactly 2 API calls regardless of conversation length.
|
|
33
|
+
# 1) Summarize on old model (input tokens proportional to conversation)
|
|
34
|
+
# 2) Inject summary into new model (small fixed-size prompt)
|
|
35
|
+
# Faster than :replay for conversations with >2 turns, but loses
|
|
36
|
+
# per-turn context fidelity. Total latency ~4-10s for cloud models.
|
|
37
|
+
def summarize_history(old_robot, new_robot)
|
|
38
|
+
return unless old_robot.respond_to?(:messages) && old_robot.messages.any?
|
|
39
|
+
|
|
40
|
+
summary_lines = old_robot.messages.map do |msg|
|
|
41
|
+
"#{msg.role}: #{msg.content}" if msg.respond_to?(:role)
|
|
42
|
+
end.compact
|
|
43
|
+
|
|
44
|
+
return if summary_lines.empty?
|
|
45
|
+
|
|
46
|
+
summary_prompt = "Summarize this conversation concisely for context transfer:\n#{summary_lines.join("\n")}"
|
|
47
|
+
summary = old_robot.run(summary_prompt, mcp: :none, tools: :none)
|
|
48
|
+
content = summary.respond_to?(:reply) ? summary.reply : summary.to_s
|
|
49
|
+
|
|
50
|
+
new_robot.run("Context from previous conversation: #{content}", mcp: :none, tools: :none)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
$stderr.puts "Warning: History summarization failed: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/aia/input_collector.rb
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
module AIA
|
|
5
5
|
class InputCollector
|
|
6
|
-
# Collect variable values from user input via
|
|
6
|
+
# Collect variable values from user input via VariableInputCollector
|
|
7
7
|
def collect(parameters)
|
|
8
8
|
return {} if parameters.nil? || parameters.empty?
|
|
9
9
|
|
|
10
10
|
values = {}
|
|
11
|
-
input_manager = AIA::
|
|
11
|
+
input_manager = AIA::VariableInputCollector.new
|
|
12
12
|
|
|
13
13
|
parameters.each do |name, default|
|
|
14
14
|
value = input_manager.request_variable_value(
|
|
15
15
|
variable_name: name,
|
|
16
|
-
default_value: default
|
|
16
|
+
default_value: default
|
|
17
17
|
)
|
|
18
18
|
values[name] = value
|
|
19
19
|
end
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/layered_orchestrator.rb
|
|
4
|
+
#
|
|
5
|
+
# Three-tier agent orchestration for complex application builds.
|
|
6
|
+
#
|
|
7
|
+
# Tier 1 — Orchestrator (Tobor)
|
|
8
|
+
# Receives full application requirements. Decomposes them into
|
|
9
|
+
# independent architectural layers (e.g. infrastructure, data models,
|
|
10
|
+
# auth, routes, views). Each layer is a distinct technical concern.
|
|
11
|
+
#
|
|
12
|
+
# Tier 2 — Lead Agents (one per layer, run in parallel via Async::Barrier)
|
|
13
|
+
# Each lead agent receives its layer's requirements and breaks them
|
|
14
|
+
# into specific implementation tasks, assigning a specialist type to
|
|
15
|
+
# each task.
|
|
16
|
+
#
|
|
17
|
+
# Tier 3 — Specialist Robots (one per task, all run in parallel via Async::Barrier)
|
|
18
|
+
# Each specialist receives a focused, concrete task and produces the
|
|
19
|
+
# actual implementation artifact: a code file, migration, spec, or
|
|
20
|
+
# configuration block.
|
|
21
|
+
#
|
|
22
|
+
# After all layers complete, Tobor synthesizes a final integration
|
|
23
|
+
# summary showing how the pieces fit together.
|
|
24
|
+
|
|
25
|
+
require 'json'
|
|
26
|
+
require 'fileutils'
|
|
27
|
+
require 'async'
|
|
28
|
+
|
|
29
|
+
module AIA
|
|
30
|
+
class LayeredOrchestrator
|
|
31
|
+
include ContentExtractor
|
|
32
|
+
include HandlerProtocol
|
|
33
|
+
|
|
34
|
+
MAX_LAYERS = 5
|
|
35
|
+
MAX_TASKS_PER_LAYER = 4
|
|
36
|
+
|
|
37
|
+
BUILD_BANNER = <<~BANNER
|
|
38
|
+
╔══════════════════════════════════════════════════════════╗
|
|
39
|
+
║ LAYERED ORCHESTRATION — 3-TIER BUILD ║
|
|
40
|
+
╚══════════════════════════════════════════════════════════╝
|
|
41
|
+
BANNER
|
|
42
|
+
|
|
43
|
+
# Tier 1 prompt: decompose requirements into layers
|
|
44
|
+
# Deliberately short and directive to minimise qwen3 think-block noise.
|
|
45
|
+
LAYER_DECOMPOSE_PROMPT = <<~PROMPT
|
|
46
|
+
TASK: Decompose the application requirements below into architectural layers.
|
|
47
|
+
RESPOND WITH ONLY A JSON ARRAY. No explanation. No markdown. No code fences.
|
|
48
|
+
|
|
49
|
+
JSON schema (array of objects):
|
|
50
|
+
name - snake_case layer identifier
|
|
51
|
+
title - short human-readable title
|
|
52
|
+
description - one sentence describing what this layer covers
|
|
53
|
+
requirements - comma-separated list of things to implement in this layer
|
|
54
|
+
|
|
55
|
+
Rules: 3 to %{max_layers} layers. Infrastructure first, UI last.
|
|
56
|
+
|
|
57
|
+
APPLICATION REQUIREMENTS:
|
|
58
|
+
%{requirements}
|
|
59
|
+
|
|
60
|
+
JSON ARRAY RESPONSE:
|
|
61
|
+
PROMPT
|
|
62
|
+
|
|
63
|
+
# Tier 2 prompt: lead agent decomposes its layer into tasks
|
|
64
|
+
TASK_DECOMPOSE_PROMPT = <<~PROMPT
|
|
65
|
+
TASK: Break the %{layer_title} layer into implementation tasks.
|
|
66
|
+
RESPOND WITH ONLY A JSON ARRAY. No explanation. No markdown. No code fences.
|
|
67
|
+
|
|
68
|
+
JSON schema (array of objects):
|
|
69
|
+
title - short task title
|
|
70
|
+
specialist - specialist role name (e.g. sequel-migration-writer)
|
|
71
|
+
artifact - exact filename to produce (e.g. db/migrations/001_create_users.rb)
|
|
72
|
+
prompt - self-contained instruction for the specialist
|
|
73
|
+
|
|
74
|
+
Rules: 2 to %{max_tasks} tasks. Each task produces exactly one file.
|
|
75
|
+
|
|
76
|
+
LAYER: %{layer_title}
|
|
77
|
+
REQUIREMENTS: %{requirements}
|
|
78
|
+
|
|
79
|
+
JSON ARRAY RESPONSE:
|
|
80
|
+
PROMPT
|
|
81
|
+
|
|
82
|
+
# Layer synthesis prompt
|
|
83
|
+
LAYER_SYNTHESIS_PROMPT = <<~PROMPT
|
|
84
|
+
You implemented the %{layer_title} layer. Here are the artifacts produced
|
|
85
|
+
by your specialist team:
|
|
86
|
+
|
|
87
|
+
%{task_results}
|
|
88
|
+
|
|
89
|
+
Write a brief (3-5 sentence) integration summary: what was built, how the
|
|
90
|
+
artifacts fit together, and what the next layer depends on from this one.
|
|
91
|
+
PROMPT
|
|
92
|
+
|
|
93
|
+
# Final synthesis prompt for Tobor
|
|
94
|
+
FINAL_SYNTHESIS_PROMPT = <<~PROMPT
|
|
95
|
+
All architectural layers of the application have been built by the agent teams.
|
|
96
|
+
Here is what each layer produced:
|
|
97
|
+
|
|
98
|
+
%{layer_summaries}
|
|
99
|
+
|
|
100
|
+
Original requirements excerpt:
|
|
101
|
+
%{requirements_excerpt}
|
|
102
|
+
|
|
103
|
+
Provide a final integration summary:
|
|
104
|
+
1. What was built overall
|
|
105
|
+
2. How the layers connect (what each layer depends on from the layers below it)
|
|
106
|
+
3. The minimal steps to make the application runnable (config, migrations, startup)
|
|
107
|
+
PROMPT
|
|
108
|
+
|
|
109
|
+
def initialize(robot:, ui_presenter:, tracker:, build_dir: nil)
|
|
110
|
+
@robot = robot
|
|
111
|
+
@ui_presenter = ui_presenter
|
|
112
|
+
@tracker = tracker
|
|
113
|
+
@build_dir = build_dir || default_build_dir
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def default_build_dir
|
|
117
|
+
File.join(Dir.pwd, "orchestrated_build_#{Time.now.strftime('%Y%m%d_%H%M%S')}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Print orchestration progress to stdout so it appears inline with the chat.
|
|
121
|
+
# (display_info uses $stderr which is invisible in interactive sessions.)
|
|
122
|
+
def say(msg)
|
|
123
|
+
$stdout.puts msg
|
|
124
|
+
$stdout.flush
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
attr_writer :robot
|
|
128
|
+
|
|
129
|
+
# Entry point called by SpecialModeHandler.
|
|
130
|
+
#
|
|
131
|
+
# @param context [HandlerContext] — reads context.prompt as requirements text
|
|
132
|
+
# @return [String, nil] final synthesis or nil on failure
|
|
133
|
+
# :reek:TooManyStatements -- single orchestration script (banner, tier 1-3 waves, synthesis, report); the narrative order is the value
|
|
134
|
+
# :reek:DuplicateMethodCall -- say("") prints deliberate blank separator lines between build phases; not a hoistable value
|
|
135
|
+
def handle(context)
|
|
136
|
+
requirements = context.prompt
|
|
137
|
+
primary = @robot.chief
|
|
138
|
+
|
|
139
|
+
FileUtils.mkdir_p(@build_dir)
|
|
140
|
+
print_build_banner(requirements)
|
|
141
|
+
|
|
142
|
+
# Tier 1: Tobor decomposes requirements into layers
|
|
143
|
+
layers = decompose_to_layers(primary, requirements)
|
|
144
|
+
if layers.empty?
|
|
145
|
+
say("Could not decompose requirements into layers. Aborting orchestration.")
|
|
146
|
+
return nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
display_layer_plan(layers)
|
|
150
|
+
|
|
151
|
+
# Tier 2: All lead agents run in parallel (Async::Barrier)
|
|
152
|
+
say("Running #{layers.size} lead agents in parallel...")
|
|
153
|
+
layer_task_map = run_leads_wave(layers)
|
|
154
|
+
|
|
155
|
+
if layer_task_map.values.all?(&:empty?)
|
|
156
|
+
say("All lead agents failed. Aborting orchestration.")
|
|
157
|
+
raise OrchestratorError, "All lead agents failed to produce tasks"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Tier 3: All specialists run in parallel (Async::Barrier)
|
|
161
|
+
say("")
|
|
162
|
+
say("Running specialists in parallel...")
|
|
163
|
+
specialist_results = run_specialists_wave(layer_task_map)
|
|
164
|
+
|
|
165
|
+
# Assemble layer_results for synthesis (same shape as the former sequential approach)
|
|
166
|
+
layer_results = build_layer_results(layers, layer_task_map, specialist_results)
|
|
167
|
+
|
|
168
|
+
# Final synthesis by Tobor
|
|
169
|
+
say("")
|
|
170
|
+
say("━━━ Tobor synthesizing all #{layers.size} layers ━━━")
|
|
171
|
+
final_result = synthesize_all(primary, requirements, layers, layer_results)
|
|
172
|
+
final_text = extract_content(final_result)
|
|
173
|
+
|
|
174
|
+
save_final_report(final_text)
|
|
175
|
+
say("Build complete: #{@build_dir}")
|
|
176
|
+
|
|
177
|
+
record_final_turn(requirements, final_text)
|
|
178
|
+
final_text
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
report_orchestration_error(e)
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def print_build_banner(requirements)
|
|
187
|
+
say("")
|
|
188
|
+
BUILD_BANNER.each_line { |line| say(line.chomp) }
|
|
189
|
+
say("Requirements: #{requirements.lines.first.strip}")
|
|
190
|
+
say("Build output: #{@build_dir}")
|
|
191
|
+
say("")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def record_final_turn(requirements, final_text)
|
|
195
|
+
@tracker.record_turn(
|
|
196
|
+
model: AIA.config.models.first.name,
|
|
197
|
+
input: requirements,
|
|
198
|
+
result: final_text
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def report_orchestration_error(error)
|
|
203
|
+
say("✗ Orchestration error: #{error.class}: #{error.message}")
|
|
204
|
+
error.backtrace&.first(5)&.each { |line| say(" #{line}") }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Tier 1: use a probe robot to decompose requirements into layer specs
|
|
208
|
+
# :reek:TooManyStatements -- probe run plus parse-failure diagnostics and rescue reporting
|
|
209
|
+
def decompose_to_layers(robot, requirements)
|
|
210
|
+
say("Tier 1 ▶ #{robot.name} decomposing requirements into layers...")
|
|
211
|
+
probe = build_probe(robot.name + "-layer-probe")
|
|
212
|
+
prompt = LAYER_DECOMPOSE_PROMPT % {
|
|
213
|
+
requirements: requirements,
|
|
214
|
+
max_layers: MAX_LAYERS
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
result = probe.run(prompt, mcp: :none, tools: :none)
|
|
218
|
+
content = extract_content(result)
|
|
219
|
+
layers = parse_json_array(content)
|
|
220
|
+
|
|
221
|
+
if layers.empty?
|
|
222
|
+
preview = content.to_s.gsub(%r{<think>.*?</think>}m, '').strip[0, 300]
|
|
223
|
+
say(" ⚠ Layer decomposition parse failed.")
|
|
224
|
+
say(" Raw response preview: #{preview}")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
layers.first(MAX_LAYERS)
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
say(" ✗ decompose_to_layers failed: #{e.class}: #{e.message}")
|
|
230
|
+
e.backtrace&.first(3)&.each { |line| say(" #{line}") }
|
|
231
|
+
[]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def display_layer_plan(layers)
|
|
235
|
+
say("Tier 1 ▶ #{layers.size} layers identified:")
|
|
236
|
+
layers.each_with_index do |layer, i|
|
|
237
|
+
say(" #{i + 1}. #{layer['title']}: #{layer['description']}")
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Tier 2 Wave: run all lead agents in parallel via Async::Barrier.
|
|
242
|
+
# Each lead decomposes its layer into task specifications.
|
|
243
|
+
#
|
|
244
|
+
# @param layers [Array<Hash>] layer specs from Tier 1
|
|
245
|
+
# @return [Hash{ String => Array<Hash> }] layer_name => task list ([] on failure)
|
|
246
|
+
# :reek:TooManyStatements -- per-layer async fan-out with per-layer error capture; the barrier plumbing is inherent
|
|
247
|
+
def run_leads_wave(layers)
|
|
248
|
+
results = {}
|
|
249
|
+
|
|
250
|
+
Sync do
|
|
251
|
+
barrier = Async::Barrier.new
|
|
252
|
+
layers.each do |layer|
|
|
253
|
+
barrier.async do
|
|
254
|
+
name = layer['name']
|
|
255
|
+
title = layer['title']
|
|
256
|
+
probe = build_probe("#{name}-lead")
|
|
257
|
+
prompt = TASK_DECOMPOSE_PROMPT % {
|
|
258
|
+
layer_title: title,
|
|
259
|
+
requirements: layer['requirements'].to_s,
|
|
260
|
+
max_tasks: MAX_TASKS_PER_LAYER
|
|
261
|
+
}
|
|
262
|
+
result = probe.run(prompt, mcp: :none, tools: :none)
|
|
263
|
+
tasks = parse_json_array(extract_content(result)).first(MAX_TASKS_PER_LAYER)
|
|
264
|
+
say(" ✓ #{title} lead: #{tasks.size} task(s)")
|
|
265
|
+
results[name] = tasks
|
|
266
|
+
rescue => e
|
|
267
|
+
say(" ✗ #{title} lead failed: #{e.message}")
|
|
268
|
+
results[name] = []
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
barrier.wait
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
results
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Tier 3 Wave: run all specialists in parallel via Async::Barrier.
|
|
278
|
+
# Flattens tasks from all layers into a single barrier so specialists
|
|
279
|
+
# across all layers execute concurrently.
|
|
280
|
+
#
|
|
281
|
+
# @param layer_task_map [Hash{ String => Array<Hash> }] output of run_leads_wave
|
|
282
|
+
# @return [Hash{ String => Hash }] "layer_name|artifact_path" => result hash
|
|
283
|
+
# :reek:TooManyStatements -- per-task async fan-out with success/failure result recording; the barrier plumbing is inherent
|
|
284
|
+
def run_specialists_wave(layer_task_map)
|
|
285
|
+
jobs = layer_task_map.flat_map do |layer_name, tasks|
|
|
286
|
+
tasks.map { |task| { layer_name: layer_name, task: task } }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
results = {}
|
|
290
|
+
|
|
291
|
+
# rubocop:disable Metrics/BlockLength
|
|
292
|
+
Sync do
|
|
293
|
+
barrier = Async::Barrier.new
|
|
294
|
+
jobs.each do |job|
|
|
295
|
+
barrier.async do
|
|
296
|
+
task = job[:task]
|
|
297
|
+
artifact = task['artifact']
|
|
298
|
+
title = task['title']
|
|
299
|
+
specialist = task['specialist']
|
|
300
|
+
key = "#{job[:layer_name]}|#{artifact}"
|
|
301
|
+
probe = build_probe("#{specialist}-specialist")
|
|
302
|
+
|
|
303
|
+
full_prompt = "Artifact to produce: #{artifact}\n\n#{task['prompt']}"
|
|
304
|
+
result = probe.run(full_prompt, mcp: :none, tools: :none)
|
|
305
|
+
output = extract_content(result)
|
|
306
|
+
|
|
307
|
+
save_artifact(artifact, output)
|
|
308
|
+
say(" ✓ #{artifact}")
|
|
309
|
+
|
|
310
|
+
results[key] = {
|
|
311
|
+
task: title,
|
|
312
|
+
specialist: specialist,
|
|
313
|
+
artifact: artifact,
|
|
314
|
+
output: output
|
|
315
|
+
}
|
|
316
|
+
rescue => e
|
|
317
|
+
say(" ✗ #{artifact} failed: #{e.message}")
|
|
318
|
+
results[key] = {
|
|
319
|
+
task: title,
|
|
320
|
+
specialist: specialist,
|
|
321
|
+
artifact: artifact,
|
|
322
|
+
output: "[FAILED: #{e.message}]"
|
|
323
|
+
}
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
# rubocop:enable Metrics/BlockLength
|
|
327
|
+
barrier.wait
|
|
328
|
+
end
|
|
329
|
+
results
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Assemble the layer_results array expected by synthesize_all.
|
|
333
|
+
# Shape: [{ layer:, tasks: [result_hashes], summary: }]
|
|
334
|
+
#
|
|
335
|
+
# @param layers [Array<Hash>] original layer specs
|
|
336
|
+
# @param layer_task_map [Hash] output of run_leads_wave
|
|
337
|
+
# @param specialist_results [Hash] output of run_specialists_wave
|
|
338
|
+
# @return [Array<Hash>]
|
|
339
|
+
def build_layer_results(layers, layer_task_map, specialist_results)
|
|
340
|
+
layers.map do |layer|
|
|
341
|
+
name = layer['name']
|
|
342
|
+
title = layer['title']
|
|
343
|
+
tasks_for_layer = layer_task_map[name] || []
|
|
344
|
+
task_results = tasks_for_layer.map do |task|
|
|
345
|
+
key = "#{name}|#{task['artifact']}"
|
|
346
|
+
specialist_results[key] || {
|
|
347
|
+
task: task['title'],
|
|
348
|
+
specialist: task['specialist'],
|
|
349
|
+
artifact: task['artifact'],
|
|
350
|
+
output: "[FAILED: no result]"
|
|
351
|
+
}
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
summary = synthesize_layer_from_results(title, task_results)
|
|
355
|
+
say(" ✓ #{title} synthesis complete")
|
|
356
|
+
{ layer: title, tasks: task_results, summary: summary }
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Synthesize all task outputs for a layer into an integration summary.
|
|
361
|
+
# Uses a fresh probe robot (no conversation history).
|
|
362
|
+
def synthesize_layer_from_results(layer_title, task_results)
|
|
363
|
+
results_text = task_results.map do |r|
|
|
364
|
+
"### #{r[:task]} — #{r[:artifact]}\n#{r[:output]}"
|
|
365
|
+
end.join("\n\n---\n\n")
|
|
366
|
+
|
|
367
|
+
prompt = LAYER_SYNTHESIS_PROMPT % {
|
|
368
|
+
layer_title: layer_title,
|
|
369
|
+
task_results: results_text
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
probe = build_probe("#{layer_title.downcase.gsub(/\s+/, '-')}-synthesis")
|
|
373
|
+
result = probe.run(prompt, mcp: :none, tools: :none)
|
|
374
|
+
extract_content(result)
|
|
375
|
+
rescue StandardError
|
|
376
|
+
"(layer synthesis failed)"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Tobor synthesizes all layer summaries into a final integration report
|
|
380
|
+
def synthesize_all(primary, requirements, layers, layer_results)
|
|
381
|
+
summaries = layer_results.each_with_index.map do |lr, i|
|
|
382
|
+
header = "## Layer #{i + 1}: #{lr[:layer]}"
|
|
383
|
+
tasks = Array(lr[:tasks]).map { |t| "- #{t[:artifact]}: #{t[:task]}" }.join("\n")
|
|
384
|
+
"#{header}\n#{tasks}\n\nSummary: #{lr[:summary]}"
|
|
385
|
+
end.join("\n\n")
|
|
386
|
+
|
|
387
|
+
primary.run(
|
|
388
|
+
FINAL_SYNTHESIS_PROMPT % {
|
|
389
|
+
layer_summaries: summaries,
|
|
390
|
+
requirements_excerpt: requirements.lines.first(15).join
|
|
391
|
+
},
|
|
392
|
+
mcp: :none, tools: :none
|
|
393
|
+
)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Parse JSON array from model output.
|
|
397
|
+
# Tries multiple extraction strategies to handle think blocks,
|
|
398
|
+
# markdown fences, prose before/after the JSON, and partial wrapping.
|
|
399
|
+
def parse_json_array(content)
|
|
400
|
+
return [] if content.nil? || content.strip.empty?
|
|
401
|
+
|
|
402
|
+
# Strip think blocks (qwen3 reasoning models wrap output in <think>...)
|
|
403
|
+
text = content.gsub(%r{<think>.*?</think>}m, '').strip
|
|
404
|
+
|
|
405
|
+
# Strategy 1: extract content from markdown code fences
|
|
406
|
+
if (m = text.match(/```(?:json)?\s*\n?(.*?)```/m))
|
|
407
|
+
candidate = m[1].strip
|
|
408
|
+
result = try_json_parse(candidate)
|
|
409
|
+
return result unless result.empty?
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Strategy 2: find the first [...] block in the response
|
|
413
|
+
if (m = text.match(/(\[[\s\S]*\])/m))
|
|
414
|
+
result = try_json_parse(m[1])
|
|
415
|
+
return result unless result.empty?
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Strategy 3: parse the whole stripped text
|
|
419
|
+
try_json_parse(text)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def try_json_parse(text)
|
|
423
|
+
result = JSON.parse(text.strip)
|
|
424
|
+
result.is_a?(Array) ? result.grep(Hash) : []
|
|
425
|
+
rescue JSON::ParserError
|
|
426
|
+
[]
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Build a short-lived probe robot with no conversation history.
|
|
430
|
+
# Uses the primary model spec so the provider is correctly wired.
|
|
431
|
+
def build_probe(name)
|
|
432
|
+
config = AIA.config
|
|
433
|
+
run_config = RobotFactory.build_run_config(config)
|
|
434
|
+
model_spec = config.models.first
|
|
435
|
+
|
|
436
|
+
RobotFactory.build_robot(
|
|
437
|
+
model_spec,
|
|
438
|
+
name: name,
|
|
439
|
+
system_prompt: nil,
|
|
440
|
+
config: run_config
|
|
441
|
+
)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Write an artifact to the build directory.
|
|
445
|
+
# Strips markdown code fences if the entire output is wrapped in them.
|
|
446
|
+
def save_artifact(artifact_path, content)
|
|
447
|
+
return unless content && !content.strip.empty?
|
|
448
|
+
|
|
449
|
+
# Strip a single wrapping code fence if the whole content is fenced
|
|
450
|
+
code = content.strip
|
|
451
|
+
if (m = code.match(/\A```\w*\n(.*)\n```\z/m))
|
|
452
|
+
code = m[1]
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
dest = File.join(@build_dir, artifact_path)
|
|
456
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
457
|
+
File.write(dest, code)
|
|
458
|
+
rescue StandardError => e
|
|
459
|
+
say(" ⚠ Could not save #{artifact_path}: #{e.message}")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Write the integration report to BUILD_DIR/INTEGRATION_REPORT.md
|
|
463
|
+
def save_final_report(text)
|
|
464
|
+
dest = File.join(@build_dir, "INTEGRATION_REPORT.md")
|
|
465
|
+
File.write(dest, text)
|
|
466
|
+
say("Integration report: #{dest}")
|
|
467
|
+
rescue StandardError
|
|
468
|
+
# best-effort
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|
data/lib/aia/logger.rb
CHANGED
|
@@ -76,22 +76,23 @@ module AIA
|
|
|
76
76
|
def configure_llm_logger
|
|
77
77
|
return unless defined?(RubyLLM)
|
|
78
78
|
|
|
79
|
-
logger
|
|
79
|
+
logger = llm_logger
|
|
80
|
+
llm_config = RubyLLM.config
|
|
80
81
|
|
|
81
82
|
# Set our logger on the RubyLLM config object
|
|
82
|
-
if
|
|
83
|
-
|
|
83
|
+
if llm_config.respond_to?(:logger=)
|
|
84
|
+
llm_config.logger = logger
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
# Also set log_file and log_level in case the logger gets recreated
|
|
87
|
-
if
|
|
88
|
+
if llm_config.respond_to?(:log_file=)
|
|
88
89
|
file = effective_log_file(logger_config_for(:llm))
|
|
89
|
-
|
|
90
|
+
llm_config.log_file = resolve_log_file_io(file)
|
|
90
91
|
end
|
|
91
92
|
|
|
92
|
-
if
|
|
93
|
+
if llm_config.respond_to?(:log_level=)
|
|
93
94
|
level = effective_log_level(logger_config_for(:llm))
|
|
94
|
-
|
|
95
|
+
llm_config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
# Reset the memoized @logger on RubyLLM module so next call uses our config
|
|
@@ -124,10 +125,9 @@ module AIA
|
|
|
124
125
|
config.log_file = resolve_log_file_io(file)
|
|
125
126
|
end
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
end
|
|
128
|
+
return unless config.respond_to?(:log_level=)
|
|
129
|
+
level = effective_log_level(logger_config_for(:mcp))
|
|
130
|
+
config.log_level = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
# Convert log file specification to IO object or file path
|
|
@@ -160,6 +160,27 @@ module AIA
|
|
|
160
160
|
@test_mode = false
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
+
# Update existing loggers' levels in-place from current config.
|
|
164
|
+
# Call this after runtime config changes (e.g. /config debug = false)
|
|
165
|
+
# so the log level takes effect immediately without recreating loggers.
|
|
166
|
+
def reconfigure_levels!
|
|
167
|
+
return if test_mode?
|
|
168
|
+
|
|
169
|
+
%i[aia llm mcp].each do |system|
|
|
170
|
+
logger = instance_variable_get(:"@#{system}_logger")
|
|
171
|
+
next unless logger
|
|
172
|
+
|
|
173
|
+
config = logger_config_for(system)
|
|
174
|
+
level = effective_log_level(config, system)
|
|
175
|
+
numeric = LOG_LEVELS.fetch(level, Lumberjack::Severity::WARN)
|
|
176
|
+
logger.level = numeric
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Keep RubyLLM / MCP configs in sync so they don't override us
|
|
180
|
+
configure_llm_logger
|
|
181
|
+
configure_mcp_logger
|
|
182
|
+
end
|
|
183
|
+
|
|
163
184
|
# =======================================================================
|
|
164
185
|
# Test Mode Support
|
|
165
186
|
# =======================================================================
|
|
@@ -287,23 +308,21 @@ module AIA
|
|
|
287
308
|
# @param file [String] The file config value
|
|
288
309
|
# @param flush [Boolean] If true, flush immediately (no buffering)
|
|
289
310
|
# @return [Lumberjack::Device] The device instance
|
|
311
|
+
# :reek:BooleanParameter -- flush: maps directly onto Lumberjack's autoflush; two constructors would obscure that single toggle
|
|
290
312
|
def create_device(file, flush: true)
|
|
291
|
-
# buffer_size: 0 means immediate flush (no buffering)
|
|
292
|
-
buffer_size = flush ? 0 : 8192
|
|
293
|
-
|
|
294
313
|
case file.to_s.upcase
|
|
295
314
|
when 'STDOUT'
|
|
296
|
-
Lumberjack::Device::Writer.new($stdout,
|
|
315
|
+
Lumberjack::Device::Writer.new($stdout, autoflush: flush)
|
|
297
316
|
when 'STDERR'
|
|
298
|
-
Lumberjack::Device::Writer.new($stderr,
|
|
317
|
+
Lumberjack::Device::Writer.new($stderr, autoflush: flush)
|
|
299
318
|
else
|
|
300
319
|
path = File.expand_path(file)
|
|
301
|
-
#
|
|
302
|
-
#
|
|
303
|
-
Lumberjack::Device::
|
|
320
|
+
# Daily date rolling via Logger::LogDevice, which also makes the
|
|
321
|
+
# file safe for multiple loggers to write to
|
|
322
|
+
Lumberjack::Device::LogFile.new(
|
|
304
323
|
path,
|
|
305
|
-
|
|
306
|
-
|
|
324
|
+
shift_age: 'daily',
|
|
325
|
+
autoflush: flush
|
|
307
326
|
)
|
|
308
327
|
end
|
|
309
328
|
end
|
|
@@ -313,12 +332,13 @@ module AIA
|
|
|
313
332
|
# @param system [Symbol] The system (:aia, :llm, :mcp)
|
|
314
333
|
# @return [ConfigSection, nil] The configuration section
|
|
315
334
|
def logger_config_for(system)
|
|
316
|
-
|
|
335
|
+
logger_cfg = AIA.config&.logger
|
|
336
|
+
return nil unless logger_cfg
|
|
317
337
|
|
|
318
338
|
case system
|
|
319
|
-
when :aia then
|
|
320
|
-
when :llm then
|
|
321
|
-
when :mcp then
|
|
339
|
+
when :aia then logger_cfg.aia
|
|
340
|
+
when :llm then logger_cfg.llm
|
|
341
|
+
when :mcp then logger_cfg.mcp
|
|
322
342
|
end
|
|
323
343
|
rescue NoMethodError
|
|
324
344
|
nil
|