aia 1.1.0 → 2.0.0.0.pre.alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +5 -1
- data/.loki +231 -0
- data/.quality/flay_baseline.txt +1 -0
- data/.quality/flog_baseline.txt +29 -0
- data/.quality/reek_baseline.txt +80 -0
- data/.rubocop.yml +116 -0
- data/.version +1 -1
- data/CHANGELOG.md +266 -42
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +266 -238
- data/Rakefile +118 -5
- data/architecture_review.md +314 -0
- data/bin/aia +16 -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 +19 -0
- data/examples/context/tech_stack.md +2 -2
- data/examples/prompts_dir/project_summary +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 +306 -159
- data/lib/aia/config/cli_parser.rb +174 -111
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +39 -46
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +121 -138
- data/lib/aia/config.rb +110 -145
- data/lib/aia/content_extractor.rb +153 -0
- data/lib/aia/cost_calculator.rb +38 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +166 -0
- data/lib/aia/delegate_handler.rb +112 -0
- data/lib/aia/directive.rb +33 -18
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +160 -20
- data/lib/aia/directives/context_directives.rb +38 -26
- data/lib/aia/directives/execution_directives.rb +136 -4
- data/lib/aia/directives/model_directives.rb +76 -34
- data/lib/aia/directives/trakflow_directives.rb +44 -0
- data/lib/aia/directives/utility_directives.rb +203 -6
- data/lib/aia/directives/web_and_file_directives.rb +96 -60
- 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 +448 -0
- data/lib/aia/logger.rb +24 -4
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +305 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_utility.rb +57 -0
- data/lib/aia/mention_router.rb +260 -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 +155 -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 +262 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +208 -0
- data/lib/aia/prompt_decomposer.rb +157 -0
- data/lib/aia/prompt_handler.rb +19 -39
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +334 -0
- data/lib/aia/robot_namer.rb +116 -0
- data/lib/aia/session.rb +83 -17
- data/lib/aia/session_tracker.rb +209 -0
- data/lib/aia/similarity_scorer.rb +39 -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 +302 -0
- data/lib/aia/startup_coordinator.rb +150 -0
- data/lib/aia/streaming_runner.rb +169 -0
- data/lib/aia/system_prompt_assembler.rb +88 -0
- data/lib/aia/task_coordinator.rb +202 -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/tool_filter/tfidf.rb +116 -0
- data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
- data/lib/aia/tool_filter.rb +82 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +143 -0
- data/lib/aia/tool_loader.rb +210 -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 +114 -0
- data/lib/aia/trakflow_bridge.rb +173 -0
- data/lib/aia/turn_state.rb +94 -0
- data/lib/aia/ui_presenter.rb +166 -198
- data/lib/aia/utility.rb +134 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
- data/lib/aia/verification_network.rb +58 -0
- data/lib/aia.rb +108 -63
- data/mkdocs.yml +1 -0
- metadata +179 -56
- 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 -164
- 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,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/model_alias_registry.rb
|
|
4
|
+
#
|
|
5
|
+
# Configurable alias table that maps short names, provider names,
|
|
6
|
+
# and capability descriptors to model IDs.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
class ModelAliasRegistry
|
|
10
|
+
DEFAULT_ALIASES = {
|
|
11
|
+
# Short names
|
|
12
|
+
"claude" => "claude-sonnet-4-20250514",
|
|
13
|
+
"sonnet" => "claude-sonnet-4-20250514",
|
|
14
|
+
"opus" => "claude-opus-4-20250514",
|
|
15
|
+
"haiku" => "claude-haiku-4-5-20251001",
|
|
16
|
+
"gpt4" => "gpt-4o",
|
|
17
|
+
"gpt4o" => "gpt-4o",
|
|
18
|
+
"gpt4mini" => "gpt-4o-mini",
|
|
19
|
+
"gemini" => "gemini-2.0-flash",
|
|
20
|
+
"flash" => "gemini-2.0-flash",
|
|
21
|
+
"llama" => "llama-3.1-70b",
|
|
22
|
+
|
|
23
|
+
# Provider names (resolve to default model for provider)
|
|
24
|
+
"anthropic" => "claude-sonnet-4-20250514",
|
|
25
|
+
"openai" => "gpt-4o",
|
|
26
|
+
"google" => "gemini-2.0-flash",
|
|
27
|
+
"meta" => "llama-3.1-70b",
|
|
28
|
+
|
|
29
|
+
# Capability descriptors
|
|
30
|
+
"fast" => "claude-haiku-4-5-20251001",
|
|
31
|
+
"cheap" => "gpt-4o-mini",
|
|
32
|
+
"best" => "claude-opus-4-20250514",
|
|
33
|
+
"coding" => "claude-sonnet-4-20250514",
|
|
34
|
+
"vision" => "gpt-4o"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def initialize(custom_aliases = {})
|
|
38
|
+
@aliases = DEFAULT_ALIASES.merge(normalize_keys(custom_aliases))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve a single name or alias to a model ID.
|
|
42
|
+
#
|
|
43
|
+
# @param name_or_alias [String] model name, alias, or capability descriptor
|
|
44
|
+
# @return [String] resolved model ID
|
|
45
|
+
def resolve(name_or_alias)
|
|
46
|
+
normalized = normalize_key(name_or_alias)
|
|
47
|
+
@aliases[normalized] || fuzzy_match(name_or_alias) || name_or_alias.to_s.strip
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve a string containing multiple model references.
|
|
51
|
+
# Handles comma-separated, "and"-separated, and mixed formats.
|
|
52
|
+
#
|
|
53
|
+
# @param input [String] e.g., "claude and gemini", "gpt4, llama"
|
|
54
|
+
# @return [Array<String>] resolved model IDs
|
|
55
|
+
def resolve_multiple(input)
|
|
56
|
+
names = input.to_s.split(/\s*(?:,|and|&|\+)\s*/i)
|
|
57
|
+
names.map { |n| resolve(n.strip) }.uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check if a name is a known alias
|
|
61
|
+
#
|
|
62
|
+
# @param name [String] the name to check
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def known?(name)
|
|
65
|
+
@aliases.key?(normalize_key(name))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# List all known aliases
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash<String, String>]
|
|
71
|
+
def all_aliases
|
|
72
|
+
@aliases.dup
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def normalize_key(name)
|
|
78
|
+
name.to_s.strip.downcase.gsub(/[-_\s]+/, '')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_keys(hash)
|
|
82
|
+
hash.transform_keys { |k| normalize_key(k) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fuzzy_match(name)
|
|
86
|
+
return nil unless defined?(RubyLLM) && RubyLLM.respond_to?(:models)
|
|
87
|
+
|
|
88
|
+
target = name.to_s.downcase.strip
|
|
89
|
+
return nil if target.empty?
|
|
90
|
+
|
|
91
|
+
match = RubyLLM.models.find { |m| m.id.downcase.include?(target) }
|
|
92
|
+
match&.id
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/model_switch_handler.rb
|
|
4
|
+
#
|
|
5
|
+
# Handles natural language model-change intent detection.
|
|
6
|
+
# When a model-change intent is detected by the classification KB,
|
|
7
|
+
# extracts model names, confirms with the user, and reconfigures.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class ModelSwitchHandler
|
|
11
|
+
include HandlerProtocol
|
|
12
|
+
|
|
13
|
+
def initialize(alias_registry, ui_presenter)
|
|
14
|
+
@aliases = alias_registry
|
|
15
|
+
@ui = ui_presenter
|
|
16
|
+
@model_exists_cache = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Natural language model switching via KBS intent detection has been removed.
|
|
20
|
+
# Use the /model directive for model changes.
|
|
21
|
+
#
|
|
22
|
+
# @param context [HandlerContext]
|
|
23
|
+
# @return [Boolean] always false
|
|
24
|
+
def handle(context) # rubocop:disable Naming/PredicateMethod
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def handle_switch(intent, config)
|
|
31
|
+
models = extract_model_names(intent[:raw_text])
|
|
32
|
+
return false if models.empty?
|
|
33
|
+
|
|
34
|
+
resolved = models.map { |m| @aliases.resolve(m) }
|
|
35
|
+
confirm_and_apply(config, resolved)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_compare(intent, config) # rubocop:disable Naming/PredicateMethod
|
|
39
|
+
models = extract_model_names(intent[:raw_text])
|
|
40
|
+
return false if models.size < 2
|
|
41
|
+
|
|
42
|
+
resolved = models.map { |m| @aliases.resolve(m) }
|
|
43
|
+
|
|
44
|
+
@ui.display_info("Interpreted as: compare #{resolved.join(', ')}")
|
|
45
|
+
@ui.display_info("Proceed? (y/n)")
|
|
46
|
+
|
|
47
|
+
response = @ui.ask_question
|
|
48
|
+
return false unless response&.strip&.downcase == 'y'
|
|
49
|
+
|
|
50
|
+
config.flags.consensus = true
|
|
51
|
+
apply_model_change(config, resolved)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_capability_switch(intent, config)
|
|
56
|
+
capability = intent[:capability]
|
|
57
|
+
resolved = [@aliases.resolve(capability)]
|
|
58
|
+
confirm_and_apply(config, resolved)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def confirm_and_apply(config, resolved) # rubocop:disable Naming/PredicateMethod
|
|
62
|
+
@ui.display_info("Interpreted as: /model #{resolved.join(', ')}")
|
|
63
|
+
@ui.display_info("Proceed? (y/n)")
|
|
64
|
+
|
|
65
|
+
response = @ui.ask_question
|
|
66
|
+
return false unless response&.strip&.downcase == 'y'
|
|
67
|
+
|
|
68
|
+
apply_model_change(config, resolved)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_model_names(text)
|
|
73
|
+
return [] unless text
|
|
74
|
+
|
|
75
|
+
# Check each word against known aliases and model names
|
|
76
|
+
words = text.downcase.scan(/[\w.-]+/)
|
|
77
|
+
words.select { |w| @aliases.known?(w) || model_exists?(w) }.uniq
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def model_exists?(name)
|
|
81
|
+
return @model_exists_cache[name] if @model_exists_cache.key?(name)
|
|
82
|
+
@model_exists_cache[name] = begin
|
|
83
|
+
return false unless defined?(RubyLLM) && RubyLLM.respond_to?(:models)
|
|
84
|
+
!!RubyLLM.models.find(name)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def apply_model_change(config, model_names)
|
|
91
|
+
config.models = model_names.map { |name| AIA::ModelSpec.new(name: name) }
|
|
92
|
+
|
|
93
|
+
history_mode = config.respond_to?(:model_switch_history) ?
|
|
94
|
+
config.model_switch_history&.to_sym : :clean
|
|
95
|
+
|
|
96
|
+
AIA.client = RobotFactory.rebuild(config, history_mode: history_mode)
|
|
97
|
+
@ui.display_info("Model switched to: #{model_names.join(', ')}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/network_builder.rb
|
|
4
|
+
#
|
|
5
|
+
# Stateless module for building RobotLab::Network instances.
|
|
6
|
+
# Extracted from RobotFactory to reduce its size and isolate
|
|
7
|
+
# the network-specific construction logic.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
module NetworkBuilder
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Build a network where each prompt in the pipeline is a sequential step.
|
|
14
|
+
#
|
|
15
|
+
# @param config [AIA::Config]
|
|
16
|
+
# @param namer [RobotNamer]
|
|
17
|
+
# @return [RobotLab::Network]
|
|
18
|
+
def build_pipeline_network(config, namer)
|
|
19
|
+
prompts = config.pipeline
|
|
20
|
+
run_config = RobotFactory.build_run_config(config)
|
|
21
|
+
tools = ToolLoader.filtered_tools(config)
|
|
22
|
+
mcp = RobotFactory.mcp_server_configs(config)
|
|
23
|
+
model_spec = config.models.first
|
|
24
|
+
|
|
25
|
+
RobotLab.create_network(name: "aia-pipeline") do
|
|
26
|
+
prev = nil
|
|
27
|
+
prompts.each_with_index do |_prompt_id, i|
|
|
28
|
+
task_name = :"step_#{i}"
|
|
29
|
+
robot = RobotFactory.build_robot(
|
|
30
|
+
model_spec,
|
|
31
|
+
name: "#{namer.name_for(model_spec.name)}-s#{i}",
|
|
32
|
+
system_prompt: nil,
|
|
33
|
+
local_tools: tools,
|
|
34
|
+
mcp_servers: mcp,
|
|
35
|
+
config: run_config
|
|
36
|
+
)
|
|
37
|
+
task task_name, robot, depends_on: prev ? [prev] : :none
|
|
38
|
+
prev = task_name
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build a network where multiple models run in parallel.
|
|
44
|
+
#
|
|
45
|
+
# @param config [AIA::Config]
|
|
46
|
+
# @param namer [RobotNamer]
|
|
47
|
+
# @return [RobotLab::Network]
|
|
48
|
+
def build_parallel_network(config, namer)
|
|
49
|
+
run_config = RobotFactory.build_run_config(config)
|
|
50
|
+
tools = ToolLoader.filtered_tools(config)
|
|
51
|
+
mcp = RobotFactory.mcp_server_configs(config)
|
|
52
|
+
aia_config = config
|
|
53
|
+
roster = config.models.map { |spec| { name: namer.name_for(spec.name), spec: spec } }
|
|
54
|
+
build_robot = ->(entry) { build_roster_robot(entry, roster, tools, mcp, run_config, aia_config) }
|
|
55
|
+
|
|
56
|
+
RobotLab.create_network(name: "aia-parallel") do
|
|
57
|
+
roster.each do |entry|
|
|
58
|
+
task entry[:spec].internal_id.to_sym, build_robot.call(entry), depends_on: :none
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Build a consensus network: all models run in parallel, then a
|
|
64
|
+
# synthesizer merges the results.
|
|
65
|
+
#
|
|
66
|
+
# @param config [AIA::Config]
|
|
67
|
+
# @param namer [RobotNamer]
|
|
68
|
+
# @return [RobotLab::Network]
|
|
69
|
+
def build_consensus_network(config, namer)
|
|
70
|
+
run_config = RobotFactory.build_run_config(config)
|
|
71
|
+
tools = ToolLoader.filtered_tools(config)
|
|
72
|
+
mcp = RobotFactory.mcp_server_configs(config)
|
|
73
|
+
primary = config.models.first
|
|
74
|
+
aia_config = config
|
|
75
|
+
roster = config.models.map { |spec| { name: namer.name_for(spec.name), spec: spec } }
|
|
76
|
+
build_robot = ->(entry) { build_roster_robot(entry, roster, tools, mcp, run_config, aia_config) }
|
|
77
|
+
|
|
78
|
+
RobotLab.create_network(name: "aia-consensus") do
|
|
79
|
+
roster.each do |entry|
|
|
80
|
+
task entry[:spec].internal_id.to_sym, build_robot.call(entry), depends_on: :none
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
synthesizer = RobotFactory.build_robot(
|
|
84
|
+
primary,
|
|
85
|
+
name: "Weaver",
|
|
86
|
+
system_prompt: "You are a synthesizer. Review the responses from multiple AI models " \
|
|
87
|
+
"and create a unified, coherent response that captures the best " \
|
|
88
|
+
"insights from each.",
|
|
89
|
+
config: run_config
|
|
90
|
+
)
|
|
91
|
+
task :consensus, synthesizer,
|
|
92
|
+
depends_on: config.models.map { |s| s.internal_id.to_sym }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Shared helper: builds a single roster robot (used by parallel and consensus networks).
|
|
97
|
+
def build_roster_robot(entry, roster, tools, mcp, run_config, aia_config)
|
|
98
|
+
spec = entry[:spec]
|
|
99
|
+
identity = SystemPromptAssembler.build_identity_prompt(entry[:name], spec, roster)
|
|
100
|
+
base_prompt = SystemPromptAssembler.resolve_system_prompt(aia_config, spec)
|
|
101
|
+
system_prompt = [identity, base_prompt].compact.join("\n\n")
|
|
102
|
+
RobotFactory.build_robot(
|
|
103
|
+
spec,
|
|
104
|
+
name: entry[:name],
|
|
105
|
+
system_prompt: system_prompt,
|
|
106
|
+
local_tools: tools,
|
|
107
|
+
mcp_servers: mcp,
|
|
108
|
+
config: run_config
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Build a concurrent MCP network where independent server groups
|
|
113
|
+
# run in parallel with a synthesizer to merge results.
|
|
114
|
+
#
|
|
115
|
+
# @param config [AIA::Config]
|
|
116
|
+
# @param namer [RobotNamer]
|
|
117
|
+
# @param server_groups [Array<Array<Hash>>]
|
|
118
|
+
# @return [RobotLab::Network]
|
|
119
|
+
def build_concurrent_mcp_network(config, namer, server_groups)
|
|
120
|
+
run_config = RobotFactory.build_run_config(config)
|
|
121
|
+
tools = ToolLoader.filtered_tools(config)
|
|
122
|
+
model_spec = config.models.first
|
|
123
|
+
system_prompt = SystemPromptAssembler.resolve_system_prompt(config, model_spec)
|
|
124
|
+
|
|
125
|
+
network = RobotLab.create_network(name: "aia-concurrent-mcp") do
|
|
126
|
+
server_groups.each_with_index do |group, i|
|
|
127
|
+
robot = RobotFactory.build_robot(
|
|
128
|
+
model_spec,
|
|
129
|
+
name: "#{namer.name_for(model_spec.name)}-w#{i}",
|
|
130
|
+
system_prompt: system_prompt,
|
|
131
|
+
local_tools: tools,
|
|
132
|
+
mcp_servers: group.map { |s| RobotFactory.normalize_mcp_config(s) },
|
|
133
|
+
config: run_config
|
|
134
|
+
)
|
|
135
|
+
task :"mcp_worker_#{i}", robot, depends_on: :none
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
synthesizer = RobotFactory.build_robot(
|
|
139
|
+
model_spec,
|
|
140
|
+
name: "Weaver",
|
|
141
|
+
system_prompt: "You are a synthesizer. Merge the following results from " \
|
|
142
|
+
"multiple specialized queries into a coherent response. " \
|
|
143
|
+
"Preserve all relevant information. Resolve contradictions.",
|
|
144
|
+
config: run_config
|
|
145
|
+
)
|
|
146
|
+
task :synthesize, synthesizer,
|
|
147
|
+
depends_on: server_groups.each_index.map { |i| :"mcp_worker_#{i}" }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
RobotFactory.initialize_network_memory(network, config)
|
|
151
|
+
RobotFactory.setup_memory_subscriptions(network, config)
|
|
152
|
+
network
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/network_memory_manager.rb
|
|
4
|
+
#
|
|
5
|
+
# Manages shared memory initialization and subscriptions for
|
|
6
|
+
# multi-robot networks. Extracted from RobotFactory.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
class NetworkMemoryManager
|
|
10
|
+
class << self
|
|
11
|
+
# Initialize shared memory for a network with session context.
|
|
12
|
+
#
|
|
13
|
+
# @param network [RobotLab::Network]
|
|
14
|
+
# @param config [AIA::Config]
|
|
15
|
+
# @return [RobotLab::Network]
|
|
16
|
+
def initialize_memory(network, config)
|
|
17
|
+
return network unless network.respond_to?(:memory)
|
|
18
|
+
|
|
19
|
+
memory = network.memory
|
|
20
|
+
memory.data.session_id = SecureRandom.hex(8)
|
|
21
|
+
memory.data.model_count = config.models.size
|
|
22
|
+
memory.data.model_names = config.models.map(&:name)
|
|
23
|
+
memory.data.mode = config.flags.consensus ? :consensus : :parallel
|
|
24
|
+
memory.data.turn_count = 0
|
|
25
|
+
|
|
26
|
+
network
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Set up memory subscriptions for debug logging and completion tracking.
|
|
30
|
+
#
|
|
31
|
+
# @param network [RobotLab::Network]
|
|
32
|
+
# @param config [AIA::Config]
|
|
33
|
+
def setup_subscriptions(network, config)
|
|
34
|
+
return unless network.respond_to?(:memory)
|
|
35
|
+
|
|
36
|
+
memory = network.memory
|
|
37
|
+
|
|
38
|
+
if config.flags.debug
|
|
39
|
+
memory.subscribe_pattern("result_*") do |change|
|
|
40
|
+
AIA::LoggerManager.aia_logger.debug(
|
|
41
|
+
"Memory: #{change.key} by #{change.writer} at #{change.timestamp}"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
memory.set(:completed_count, 0)
|
|
47
|
+
memory.subscribe_pattern("result_*") do |change|
|
|
48
|
+
next unless change.created?
|
|
49
|
+
count = memory.get(:completed_count) || 0
|
|
50
|
+
memory.set(:completed_count, count + 1)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# lib/aia/patches/ruby_llm_streaming_error.rb
|
|
2
|
+
#
|
|
3
|
+
# Patches RubyLLM::Streaming#handle_error_chunk to locate the SSE `data:` line
|
|
4
|
+
# robustly.
|
|
5
|
+
#
|
|
6
|
+
# The shipped implementation assumes the `data:` line is the SECOND line of the
|
|
7
|
+
# same chunk as `event: error`:
|
|
8
|
+
#
|
|
9
|
+
# error_data = chunk.split("\n")[1].delete_prefix('data: ')
|
|
10
|
+
#
|
|
11
|
+
# When a provider flushes `event: error` and its `data:` line in SEPARATE stream
|
|
12
|
+
# chunks (LM Studio does this on, e.g., a context-length overflow),
|
|
13
|
+
# chunk.split("\n")[1] is nil and the real provider message is masked as the
|
|
14
|
+
# cryptic `NoMethodError: undefined method 'delete_prefix' for nil`. This
|
|
15
|
+
# version scans the chunk for the `data:` line so the actual error propagates to
|
|
16
|
+
# the user. Falls back to the whole chunk when no data line is present, which
|
|
17
|
+
# still parses cleanly instead of raising.
|
|
18
|
+
#
|
|
19
|
+
# Applied via #prepend so it cleanly overrides the buggy method while reusing
|
|
20
|
+
# ruby_llm's own private parse_error_from_json. Guarded against API drift: if
|
|
21
|
+
# parse_error_from_json is renamed/removed the patch is skipped with a warning.
|
|
22
|
+
#
|
|
23
|
+
# NOTE: This fixes a genuine upstream bug — it should be contributed to
|
|
24
|
+
# crmne/ruby_llm so this patch can eventually be retired.
|
|
25
|
+
|
|
26
|
+
module AIA
|
|
27
|
+
module RubyLLMStreamingError
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def handle_error_chunk(chunk, env)
|
|
31
|
+
data_line = chunk.to_s.lines.find { |line| line.start_with?('data:') }
|
|
32
|
+
error_data = data_line ? data_line.sub(/\Adata:\s*/, '').strip : chunk.to_s.strip
|
|
33
|
+
parse_error_from_json(error_data, env, 'Failed to parse error chunk')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if RubyLLM::Streaming.private_method_defined?(:parse_error_from_json)
|
|
39
|
+
RubyLLM::Streaming.prepend(AIA::RubyLLMStreamingError)
|
|
40
|
+
else
|
|
41
|
+
$stderr.puts "WARNING: [AIA] ruby_llm patch skipped: RubyLLM::Streaming#parse_error_from_json " \
|
|
42
|
+
"not found (ruby_llm #{RubyLLM::VERSION}). Streaming error messages may be cryptic."
|
|
43
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# lib/aia/patches/ruby_llm_tool_error.rb
|
|
2
|
+
#
|
|
3
|
+
# Two ruby_llm hardening patches:
|
|
4
|
+
#
|
|
5
|
+
# 1. RubyLLM::Chat#execute_tool — rescue any exception raised during tool
|
|
6
|
+
# execution and return it as an error hash instead of raising. Without
|
|
7
|
+
# this, an exception inside a tool skips the add_message call in
|
|
8
|
+
# handle_tool_calls, leaving an orphaned tool_call in the conversation
|
|
9
|
+
# history with no matching tool_result. Most LLMs reject that state on the
|
|
10
|
+
# next request, killing the session. ruby_llm's own execute_tool does NOT
|
|
11
|
+
# rescue tool exceptions, so this is still required as of 1.16.
|
|
12
|
+
#
|
|
13
|
+
# 2. RubyLLM::Provider#complete — normalize OpenAI o-series / gpt-5 params
|
|
14
|
+
# (max_tokens -> max_completion_tokens) before the request.
|
|
15
|
+
#
|
|
16
|
+
# Both patches are applied via #prepend + super so they preserve ruby_llm's own
|
|
17
|
+
# method body (including its instrumentation) and survive internal changes to
|
|
18
|
+
# that body. They are guarded against drift: if a future ruby_llm renames or
|
|
19
|
+
# removes the target method, the patch is skipped with a loud warning rather
|
|
20
|
+
# than silently installing dead code or breaking every request.
|
|
21
|
+
|
|
22
|
+
module AIA
|
|
23
|
+
class OpenAIParameterNormalizer
|
|
24
|
+
class << self
|
|
25
|
+
def normalize(params, model, provider)
|
|
26
|
+
normalized = params.dup
|
|
27
|
+
return normalized unless uses_completion_tokens_param?(model, provider)
|
|
28
|
+
return normalized unless normalized.key?(:max_tokens)
|
|
29
|
+
|
|
30
|
+
max_tokens = normalized.delete(:max_tokens)
|
|
31
|
+
normalized[:max_completion_tokens] ||= max_tokens
|
|
32
|
+
normalized
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def uses_completion_tokens_param?(model, provider)
|
|
38
|
+
provider_slug(provider) == 'openai' && model_id(model).match?(/\A(o\d|gpt-5)/i)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def provider_slug(provider)
|
|
42
|
+
provider.respond_to?(:slug) ? provider.slug.to_s : nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def model_id(model)
|
|
46
|
+
model.respond_to?(:id) ? model.id.to_s : model.to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Prepended onto RubyLLM::Provider. Uses a pass-through (*args, **kwargs)
|
|
52
|
+
# signature so any kwargs ruby_llm adds in future are forwarded to super
|
|
53
|
+
# unchanged; we only rewrite params before delegating.
|
|
54
|
+
module RubyLLMOpenAIParameters
|
|
55
|
+
def complete(*, **kwargs, &)
|
|
56
|
+
kwargs[:params] = AIA::OpenAIParameterNormalizer.normalize(
|
|
57
|
+
kwargs[:params] || {}, kwargs[:model], self
|
|
58
|
+
)
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Prepended onto RubyLLM::Chat. Calls super (ruby_llm's own instrumented
|
|
64
|
+
# execute_tool) and only adds exception rescue, so tool-call instrumentation
|
|
65
|
+
# is preserved.
|
|
66
|
+
module RubyLLMChatToolError
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def execute_tool(tool_call)
|
|
70
|
+
super
|
|
71
|
+
rescue SignalException, SystemExit
|
|
72
|
+
raise
|
|
73
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
74
|
+
RubyLLM.logger.warn { "Tool #{tool_call.name} raised #{e.class}: #{e.message}" }
|
|
75
|
+
{ error: "#{e.class}: #{e.message}" }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# --- Apply patches, guarded against ruby_llm API drift ---
|
|
81
|
+
|
|
82
|
+
if RubyLLM::Provider.private_method_defined?(:complete) ||
|
|
83
|
+
RubyLLM::Provider.method_defined?(:complete)
|
|
84
|
+
RubyLLM::Provider.prepend(AIA::RubyLLMOpenAIParameters)
|
|
85
|
+
else
|
|
86
|
+
$stderr.puts "WARNING: [AIA] ruby_llm patch skipped: RubyLLM::Provider#complete " \
|
|
87
|
+
"not found (ruby_llm #{RubyLLM::VERSION}). OpenAI param normalization is off."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if RubyLLM::Chat.private_method_defined?(:execute_tool) ||
|
|
91
|
+
RubyLLM::Chat.method_defined?(:execute_tool)
|
|
92
|
+
RubyLLM::Chat.prepend(AIA::RubyLLMChatToolError)
|
|
93
|
+
else
|
|
94
|
+
$stderr.puts "WARNING: [AIA] ruby_llm patch skipped: RubyLLM::Chat#execute_tool " \
|
|
95
|
+
"not found (ruby_llm #{RubyLLM::VERSION}). Tool-exception handling is degraded."
|
|
96
|
+
end
|