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,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
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/pipeline_orchestrator.rb
|
|
4
|
+
#
|
|
5
|
+
# Handles per-prompt pipeline processing: building prompt text,
|
|
6
|
+
# executing against the robot, tracking results, and displaying output.
|
|
7
|
+
# Extracted from Session to separate startup concerns from per-turn concerns.
|
|
8
|
+
|
|
9
|
+
require "json"
|
|
10
|
+
require_relative 'skill_utils'
|
|
11
|
+
|
|
12
|
+
module AIA
|
|
13
|
+
class PipelineOrchestrator
|
|
14
|
+
include ContentExtractor
|
|
15
|
+
include SkillUtils
|
|
16
|
+
|
|
17
|
+
def initialize(robot:, prompt_handler:, input_collector:, ui_presenter:, session_tracker:)
|
|
18
|
+
@robot = robot
|
|
19
|
+
@prompt_handler = prompt_handler
|
|
20
|
+
@input_collector = input_collector
|
|
21
|
+
@ui = ui_presenter
|
|
22
|
+
@tracker = session_tracker
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Process all prompts in the pipeline.
|
|
26
|
+
#
|
|
27
|
+
# @param config [AIA::Config]
|
|
28
|
+
# :reek:TooManyStatements -- shift-based loop so front-matter pipeline edits take effect; tracking interleaved
|
|
29
|
+
# :reek:DuplicateMethodCall -- config.pipeline is deliberately re-read each iteration because prompt front matter mutates it mid-loop
|
|
30
|
+
def process(config)
|
|
31
|
+
bridge = TrakFlowBridge.new
|
|
32
|
+
tracking = bridge.available? && config.flags.track_pipeline
|
|
33
|
+
|
|
34
|
+
bridge.create_plan_from_pipeline(config.pipeline) if tracking
|
|
35
|
+
|
|
36
|
+
# Use shift-based loop so that prompt front matter which sets
|
|
37
|
+
# config.pipeline (via `next:` or `pipeline:`) is picked up on
|
|
38
|
+
# the next iteration rather than being silently ignored.
|
|
39
|
+
until config.pipeline.empty?
|
|
40
|
+
prompt_id = config.pipeline.shift
|
|
41
|
+
next if prompt_id.nil? || prompt_id.empty?
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
bridge.update_step_status(prompt_id, :started) if tracking
|
|
45
|
+
|
|
46
|
+
prompt_text = build_prompt_text(prompt_id, config)
|
|
47
|
+
next if prompt_text.nil? || prompt_text.strip.empty?
|
|
48
|
+
|
|
49
|
+
result, content = compute_result(prompt_text, config)
|
|
50
|
+
|
|
51
|
+
bridge.update_step_status(prompt_id, :completed) if tracking
|
|
52
|
+
|
|
53
|
+
@tracker.record_turn(
|
|
54
|
+
model: config.models.first.name,
|
|
55
|
+
input: prompt_text,
|
|
56
|
+
result: result
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@ui.display_ai_response(content)
|
|
60
|
+
output_to_file(content, config)
|
|
61
|
+
display_metrics(result, config)
|
|
62
|
+
@ui.display_separator
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
bridge.update_step_status(prompt_id, :failed, reason: e.message) if tracking
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Allow robot to be updated (after model switch)
|
|
71
|
+
attr_writer :robot
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Returns [result, displayable_content] for one prompt — image when in paint
|
|
76
|
+
# mode, otherwise the LLM text response.
|
|
77
|
+
def compute_result(prompt_text, config)
|
|
78
|
+
if AIA.paint?
|
|
79
|
+
image = generate_image(prompt_text, config)
|
|
80
|
+
[image, image]
|
|
81
|
+
else
|
|
82
|
+
result = execute_prompt(prompt_text, config)
|
|
83
|
+
[result, extract_content(result)]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# :reek:TooManyStatements -- image request assembly, spinner-wrapped generation, then save/display of the result
|
|
88
|
+
def generate_image(prompt_text, config)
|
|
89
|
+
img_cfg = config.image
|
|
90
|
+
quality = img_cfg.quality
|
|
91
|
+
style = img_cfg.style
|
|
92
|
+
params = {}
|
|
93
|
+
params[:quality] = quality if quality && !quality.to_s.strip.empty?
|
|
94
|
+
params[:style] = style if style && !style.to_s.strip.empty?
|
|
95
|
+
|
|
96
|
+
image = @ui.with_spinner("Generating image") do
|
|
97
|
+
RubyLLM.paint(
|
|
98
|
+
prompt_text,
|
|
99
|
+
model: img_cfg.model || 'dall-e-3',
|
|
100
|
+
size: img_cfg.size || '1024x1024',
|
|
101
|
+
params: params
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
out_file = config.output.file
|
|
106
|
+
if out_file
|
|
107
|
+
ext = '.png'
|
|
108
|
+
path = out_file.chomp(File.extname(out_file)) + ext
|
|
109
|
+
image.save(path)
|
|
110
|
+
"Image saved to: #{path}"
|
|
111
|
+
else
|
|
112
|
+
image.url || "(image returned no URL)"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Execute a prompt, optionally using concurrent MCP
|
|
117
|
+
def execute_prompt(prompt_text, config)
|
|
118
|
+
concurrent_network = maybe_use_concurrent_mcp(prompt_text, config)
|
|
119
|
+
|
|
120
|
+
if concurrent_network
|
|
121
|
+
@ui.with_spinner("Processing (concurrent)") do
|
|
122
|
+
concurrent_network.run(prompt_text)
|
|
123
|
+
end
|
|
124
|
+
else
|
|
125
|
+
@ui.with_spinner("Processing") do
|
|
126
|
+
@robot.run(prompt_text, mcp: :inherit, tools: :inherit)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Check if concurrent MCP mode should be used
|
|
132
|
+
# :reek:TooManyStatements -- ordered eligibility gates (server count, auto/forced, discovery, grouping threshold) before the build
|
|
133
|
+
def maybe_use_concurrent_mcp(prompt_text, config)
|
|
134
|
+
return nil unless (config.mcp_servers || []).size > 1
|
|
135
|
+
|
|
136
|
+
turn_state = AIA.turn_state
|
|
137
|
+
concurrency = config.respond_to?(:concurrency) ? config.concurrency : nil
|
|
138
|
+
return nil unless concurrency&.auto || turn_state.force_concurrent_mcp
|
|
139
|
+
|
|
140
|
+
if turn_state.force_concurrent_mcp
|
|
141
|
+
turn_state.force_concurrent_mcp = false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
discovery = MCPDiscovery.new
|
|
145
|
+
relevant_servers = discovery.discover(config)
|
|
146
|
+
return nil if relevant_servers.size <= 1
|
|
147
|
+
|
|
148
|
+
grouper = MCPGrouper.new
|
|
149
|
+
groups = grouper.group(relevant_servers)
|
|
150
|
+
|
|
151
|
+
threshold = concurrency.respond_to?(:threshold) ? (concurrency.threshold || 2) : 2
|
|
152
|
+
return nil if groups.size < threshold
|
|
153
|
+
|
|
154
|
+
RobotFactory.build_concurrent_mcp_network(config, groups)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
$stderr.puts "Warning: Concurrent MCP setup failed: #{e.message}"
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Build prompt text from a prompt_id
|
|
161
|
+
# :reek:TooManyStatements -- layered prompt assembly: parameters, role prefix, skills, stdin, context files, in a fixed order
|
|
162
|
+
def build_prompt_text(prompt_id, config)
|
|
163
|
+
parsed = @prompt_handler.fetch_prompt(prompt_id)
|
|
164
|
+
return nil unless parsed
|
|
165
|
+
|
|
166
|
+
parameters = parsed.respond_to?(:parameters) ? parsed.parameters : nil
|
|
167
|
+
if parameters && !parameters.empty?
|
|
168
|
+
values = @input_collector.collect(parameters)
|
|
169
|
+
values.each { |k, v| parameters[k] = v }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
prompt_text = parsed.to_s
|
|
173
|
+
|
|
174
|
+
role = config.prompts.role
|
|
175
|
+
if role && !role.empty?
|
|
176
|
+
role_parsed = @prompt_handler.fetch_role(role)
|
|
177
|
+
if role_parsed
|
|
178
|
+
prompt_text = "#{role_parsed}\n\n#{prompt_text}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# In pipeline mode, append --skill content to each prompt text.
|
|
183
|
+
# In chat mode, skills are injected into the system prompt instead (system_prompt_assembler).
|
|
184
|
+
unless config.flags&.chat == true
|
|
185
|
+
skill_content = load_skills_content(
|
|
186
|
+
Array(config.prompts&.skills),
|
|
187
|
+
skills_base_dir(config)
|
|
188
|
+
)
|
|
189
|
+
prompt_text = "#{prompt_text}\n\n#{skill_content}" if skill_content
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
stdin_content = config.stdin_content
|
|
193
|
+
if stdin_content && !stdin_content.strip.empty?
|
|
194
|
+
prompt_text = "#{prompt_text}\n\n#{stdin_content}"
|
|
195
|
+
config.stdin_content = nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
add_context_files(prompt_text, config)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Append context file contents to prompt
|
|
202
|
+
def add_context_files(prompt_text, config)
|
|
203
|
+
context_files = config.context_files
|
|
204
|
+
return prompt_text if context_files.nil? || context_files.empty?
|
|
205
|
+
|
|
206
|
+
context = context_files.map do |file|
|
|
207
|
+
File.read(file) rescue "Error reading file: #{file}"
|
|
208
|
+
end.join("\n\n")
|
|
209
|
+
|
|
210
|
+
return prompt_text if context.strip.empty?
|
|
211
|
+
|
|
212
|
+
"#{prompt_text}\n\n#{context}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Display token metrics if enabled.
|
|
216
|
+
# Uses result.raw (RubyLLM::Message) for token data, matching ChatLoop's approach.
|
|
217
|
+
def display_metrics(result, config)
|
|
218
|
+
return unless config.flags.tokens
|
|
219
|
+
|
|
220
|
+
if defined?(SimpleFlow::Result) && result.is_a?(SimpleFlow::Result)
|
|
221
|
+
display_network_metrics(result)
|
|
222
|
+
return
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
raw = result.respond_to?(:raw) ? result.raw : nil
|
|
226
|
+
return unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
|
|
227
|
+
|
|
228
|
+
model_id = (raw.respond_to?(:model_id) && raw.model_id) ||
|
|
229
|
+
(raw.respond_to?(:model) && raw.model) ||
|
|
230
|
+
config.models.first.name
|
|
231
|
+
@ui.display_token_metrics(
|
|
232
|
+
model_id: model_id,
|
|
233
|
+
input_tokens: raw.input_tokens,
|
|
234
|
+
output_tokens: raw.output_tokens
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def display_network_metrics(flow_result)
|
|
239
|
+
metrics_list = []
|
|
240
|
+
flow_result.context.each do |task_name, robot_result|
|
|
241
|
+
next if task_name == :run_params
|
|
242
|
+
next unless robot_result.respond_to?(:raw)
|
|
243
|
+
|
|
244
|
+
raw = robot_result.raw
|
|
245
|
+
next unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
|
|
246
|
+
|
|
247
|
+
model_id = (raw.respond_to?(:model_id) && raw.model_id) ||
|
|
248
|
+
(raw.respond_to?(:model) && raw.model) ||
|
|
249
|
+
task_name.to_s
|
|
250
|
+
display_name = robot_result.respond_to?(:robot_name) ? robot_result.robot_name : task_name.to_s
|
|
251
|
+
metrics_list << {
|
|
252
|
+
model_id: model_id,
|
|
253
|
+
display_name: display_name,
|
|
254
|
+
input_tokens: raw.input_tokens || 0,
|
|
255
|
+
output_tokens: raw.output_tokens || 0,
|
|
256
|
+
elapsed: robot_result.respond_to?(:duration) ? robot_result.duration : nil
|
|
257
|
+
}
|
|
258
|
+
end
|
|
259
|
+
@ui.display_multi_model_metrics(metrics_list) unless metrics_list.empty?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Write content to the output file if configured
|
|
263
|
+
def output_to_file(content, config)
|
|
264
|
+
out_file = config.output.file
|
|
265
|
+
return unless out_file
|
|
266
|
+
|
|
267
|
+
File.open(out_file, "a") do |f|
|
|
268
|
+
f.puts "AI: #{content}"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AIA
|
|
4
|
+
# Loads, reloads, and unloads top-level Ruby plugin files from the configured
|
|
5
|
+
# plugins directory. A plugin is any `*.rb` file directly under
|
|
6
|
+
# `config.paths.plugins_dir`; it may define top-level (Kernel) methods and/or
|
|
7
|
+
# top-level constants. The loader tracks exactly what each file defines so a
|
|
8
|
+
# changed plugin can be cleanly reloaded and a deleted plugin fully removed
|
|
9
|
+
# from the running process.
|
|
10
|
+
class PluginLoader
|
|
11
|
+
class << self
|
|
12
|
+
# Per-process registry of loaded plugins, keyed by basename.
|
|
13
|
+
# Each value: { path:, signature:, defined: { methods: [Symbol], constants: [Symbol] } }
|
|
14
|
+
def registry
|
|
15
|
+
@registry ||= {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def mutex
|
|
19
|
+
@mutex ||= Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Load every `*.rb` file at the top level of the plugins directory,
|
|
23
|
+
# starting from a clean slate (any previously-tracked definitions are
|
|
24
|
+
# removed first). Returns the sorted basenames of the loaded plugins.
|
|
25
|
+
#
|
|
26
|
+
# @param config [#paths, #loaded_plugins=]
|
|
27
|
+
# @return [Array<String>]
|
|
28
|
+
def load!(config)
|
|
29
|
+
@config = config
|
|
30
|
+
reset!(config)
|
|
31
|
+
|
|
32
|
+
dir = plugins_dir(config)
|
|
33
|
+
return [] unless dir
|
|
34
|
+
|
|
35
|
+
Dir.glob(File.join(dir, '*.rb')).each { |file| load_file(file, config) }
|
|
36
|
+
loaded_names
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Load — or, if already loaded, reload — a single plugin file. On reload,
|
|
40
|
+
# the file's previous definitions are removed before re-executing so that
|
|
41
|
+
# renamed/deleted methods don't linger. Returns the basename, or nil on
|
|
42
|
+
# failure.
|
|
43
|
+
#
|
|
44
|
+
# @param path [String] absolute path to a `*.rb` plugin file
|
|
45
|
+
# @param config [Object, nil] config to keep `loaded_plugins` in sync with
|
|
46
|
+
# @return [String, nil]
|
|
47
|
+
def load_file(path, config = @config)
|
|
48
|
+
mutex.synchronize do
|
|
49
|
+
name = File.basename(path, '.rb')
|
|
50
|
+
remove_definitions(registry[name]) if registry.key?(name)
|
|
51
|
+
|
|
52
|
+
before = current_definitions
|
|
53
|
+
load(path)
|
|
54
|
+
defined = diff_definitions(before, current_definitions)
|
|
55
|
+
|
|
56
|
+
registry[name] = { path: path, signature: signature(path), defined: defined }
|
|
57
|
+
sync_config(config)
|
|
58
|
+
name
|
|
59
|
+
end
|
|
60
|
+
rescue ScriptError, StandardError => e
|
|
61
|
+
warn_failure(path, e)
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Unload a plugin by basename: remove the methods and constants it defined
|
|
66
|
+
# and forget it. Returns the basename if it was loaded, else nil.
|
|
67
|
+
#
|
|
68
|
+
# @param name [String] plugin basename (no extension)
|
|
69
|
+
# @param config [Object, nil]
|
|
70
|
+
# @return [String, nil]
|
|
71
|
+
def unload_file(name, config = @config)
|
|
72
|
+
mutex.synchronize do
|
|
73
|
+
entry = registry.delete(name)
|
|
74
|
+
remove_definitions(entry)
|
|
75
|
+
sync_config(config)
|
|
76
|
+
entry && name
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Array<String>] sorted basenames currently loaded
|
|
81
|
+
def loaded_names
|
|
82
|
+
registry.keys.sort
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remove all tracked plugin definitions and clear the registry. Used by
|
|
86
|
+
# load! for a clean reload and by tests for isolation.
|
|
87
|
+
#
|
|
88
|
+
# @param config [Object, nil]
|
|
89
|
+
# @return [void]
|
|
90
|
+
def reset!(config = @config)
|
|
91
|
+
mutex.synchronize do
|
|
92
|
+
registry.each_value { |entry| remove_definitions(entry) }
|
|
93
|
+
registry.clear
|
|
94
|
+
sync_config(config)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def plugins_dir(config)
|
|
101
|
+
dir = config.paths&.plugins_dir
|
|
102
|
+
return nil if dir.nil? || dir.to_s.strip.empty?
|
|
103
|
+
return nil unless Dir.exist?(dir)
|
|
104
|
+
|
|
105
|
+
dir
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Snapshot of the top-level methods and constants currently defined on
|
|
109
|
+
# Object. Top-level `def` creates private instance methods on Object;
|
|
110
|
+
# top-level constants/classes/modules become constants on Object.
|
|
111
|
+
#
|
|
112
|
+
# @return [Hash{Symbol=>Array<Symbol>}]
|
|
113
|
+
def current_definitions
|
|
114
|
+
{
|
|
115
|
+
methods: Object.private_instance_methods(false) + Object.instance_methods(false),
|
|
116
|
+
constants: Object.constants(false)
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# What appeared between two snapshots.
|
|
121
|
+
def diff_definitions(before, after)
|
|
122
|
+
{
|
|
123
|
+
methods: after[:methods] - before[:methods],
|
|
124
|
+
constants: after[:constants] - before[:constants]
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Remove the methods and constants a plugin defined, if still present and
|
|
129
|
+
# owned directly by Object.
|
|
130
|
+
def remove_definitions(entry)
|
|
131
|
+
return unless entry
|
|
132
|
+
|
|
133
|
+
defined = entry[:defined] || {}
|
|
134
|
+
Array(defined[:methods]).each { |m| remove_object_method(m) }
|
|
135
|
+
Array(defined[:constants]).each { |c| remove_object_constant(c) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def remove_object_method(name)
|
|
139
|
+
return unless Object.private_method_defined?(name, false) ||
|
|
140
|
+
Object.method_defined?(name, false)
|
|
141
|
+
|
|
142
|
+
Object.send(:remove_method, name)
|
|
143
|
+
rescue NameError
|
|
144
|
+
# Already gone — nothing to do.
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def remove_object_constant(name)
|
|
148
|
+
Object.send(:remove_const, name) if Object.const_defined?(name, false)
|
|
149
|
+
rescue NameError
|
|
150
|
+
# Already gone — nothing to do.
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A cheap change-signature for a file: [mtime, size]. Nil if unreadable.
|
|
154
|
+
def signature(path)
|
|
155
|
+
stat = File.stat(path)
|
|
156
|
+
[stat.mtime, stat.size]
|
|
157
|
+
rescue SystemCallError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def sync_config(config)
|
|
162
|
+
config.loaded_plugins = loaded_names if config.respond_to?(:loaded_plugins=)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def warn_failure(path, error)
|
|
166
|
+
$stderr.puts "Warning: Failed to load plugin '#{path}': #{error.message}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|