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,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'plugin_loader'
|
|
4
|
+
|
|
5
|
+
module AIA
|
|
6
|
+
# Watches the plugins directory and keeps the running process in sync with it:
|
|
7
|
+
#
|
|
8
|
+
# - a new `*.rb` file -> loaded
|
|
9
|
+
# - an existing file changed -> reloaded (old definitions removed first)
|
|
10
|
+
# - a file removed -> its definitions removed from the process
|
|
11
|
+
#
|
|
12
|
+
# Detection prefers the `listen` gem (native OS file events; instant, no idle
|
|
13
|
+
# CPU). If `listen` cannot be loaded, it falls back to a background thread that
|
|
14
|
+
# polls file signatures ([mtime, size]). Either way the same #react logic drives
|
|
15
|
+
# {PluginLoader}, so behavior is identical — only latency differs.
|
|
16
|
+
# :reek:TooManyInstanceVariables -- holds both backends' state (listener vs polling thread) plus watch config and callbacks
|
|
17
|
+
class PluginMonitor
|
|
18
|
+
DEFAULT_INTERVAL = 1.0 # seconds (polling fallback only)
|
|
19
|
+
|
|
20
|
+
# @param config [#paths] provides paths.plugins_dir
|
|
21
|
+
# @param loader [#load_file, #unload_file] plugin loader (default PluginLoader)
|
|
22
|
+
# @param interval [Float] seconds between scans (polling fallback only)
|
|
23
|
+
# @param output [IO] where status messages go (default $stderr)
|
|
24
|
+
def initialize(config, loader: PluginLoader, interval: DEFAULT_INTERVAL, output: $stderr)
|
|
25
|
+
@config = config
|
|
26
|
+
@loader = loader
|
|
27
|
+
@interval = interval
|
|
28
|
+
@output = output
|
|
29
|
+
@dir = config.paths&.plugins_dir
|
|
30
|
+
@thread = nil
|
|
31
|
+
@listener = nil
|
|
32
|
+
@running = false
|
|
33
|
+
@backend = nil
|
|
34
|
+
@snapshot = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Start watching. Prefers listen; falls back to polling. No-op if the
|
|
38
|
+
# directory is not watchable or the monitor is already running.
|
|
39
|
+
#
|
|
40
|
+
# @return [self]
|
|
41
|
+
def start
|
|
42
|
+
return self unless watchable?
|
|
43
|
+
return self if @running
|
|
44
|
+
|
|
45
|
+
@snapshot = current_snapshot
|
|
46
|
+
@running = true
|
|
47
|
+
@backend = start_listen ? :listen : start_polling
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Stop watching, whichever backend is active.
|
|
52
|
+
#
|
|
53
|
+
# @return [self]
|
|
54
|
+
def stop
|
|
55
|
+
@running = false
|
|
56
|
+
stop_listen
|
|
57
|
+
stop_polling
|
|
58
|
+
@backend = nil
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def running?
|
|
64
|
+
@running
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Symbol, nil] :listen or :polling once started
|
|
68
|
+
attr_reader :backend
|
|
69
|
+
|
|
70
|
+
# Perform one scan-and-react cycle (polling backend). Compares the current
|
|
71
|
+
# directory state to the previous snapshot, applies each change, then stores
|
|
72
|
+
# the new snapshot. Public so it can be driven directly in tests.
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<Array(Symbol, String)>] the changes applied this tick
|
|
75
|
+
def tick
|
|
76
|
+
now = current_snapshot
|
|
77
|
+
changes = detect_changes(@snapshot, now)
|
|
78
|
+
changes.each { |event, path| react(event, path) }
|
|
79
|
+
@snapshot = now
|
|
80
|
+
changes
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Compare two `path => signature` snapshots and classify the differences.
|
|
84
|
+
#
|
|
85
|
+
# @return [Array<Array(Symbol, String)>] e.g. [[:added, "/p/foo.rb"], ...]
|
|
86
|
+
def detect_changes(old_snapshot, new_snapshot)
|
|
87
|
+
old_keys = old_snapshot.keys
|
|
88
|
+
new_keys = new_snapshot.keys
|
|
89
|
+
added = (new_keys - old_keys).map { |path| [:added, path] }
|
|
90
|
+
removed = (old_keys - new_keys).map { |path| [:removed, path] }
|
|
91
|
+
modified = (old_keys & new_keys)
|
|
92
|
+
.reject { |path| new_snapshot[path] == old_snapshot[path] }
|
|
93
|
+
.map { |path| [:modified, path] }
|
|
94
|
+
added + removed + modified
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def watchable?
|
|
100
|
+
!@dir.nil? && !@dir.to_s.strip.empty? && Dir.exist?(@dir)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- listen backend ---
|
|
104
|
+
|
|
105
|
+
# Try to start the listen-based watcher. Returns true on success, false if
|
|
106
|
+
# the listen gem is unavailable (caller then uses the polling fallback).
|
|
107
|
+
def start_listen
|
|
108
|
+
require 'listen'
|
|
109
|
+
@listener = Listen.to(@dir, only: /\.rb$/) do |modified, added, removed|
|
|
110
|
+
handle_listen_events(modified, added, removed)
|
|
111
|
+
end
|
|
112
|
+
@listener.start
|
|
113
|
+
true
|
|
114
|
+
rescue LoadError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Map a listen callback into per-file reactions, ignoring anything that is
|
|
119
|
+
# not a top-level file of the plugins directory (listen watches recursively).
|
|
120
|
+
def handle_listen_events(modified, added, removed)
|
|
121
|
+
{ added: added, modified: modified, removed: removed }.each do |event, paths|
|
|
122
|
+
Array(paths).each { |path| react(event, path) if top_level?(path) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# listen reports resolved (symlink-free) paths, and on macOS the plugins
|
|
127
|
+
# dir is often reached via a symlink (e.g. /tmp -> /private/tmp). Compare
|
|
128
|
+
# canonicalized directories so the top-level filter matches correctly.
|
|
129
|
+
def top_level?(path)
|
|
130
|
+
canonical_dir(File.dirname(path)) == watched_dir
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def watched_dir
|
|
134
|
+
@watched_dir ||= canonical_dir(@dir)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def canonical_dir(dir)
|
|
138
|
+
File.realpath(dir)
|
|
139
|
+
rescue StandardError
|
|
140
|
+
File.expand_path(dir)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def stop_listen
|
|
144
|
+
@listener&.stop
|
|
145
|
+
rescue StandardError
|
|
146
|
+
# best-effort
|
|
147
|
+
ensure
|
|
148
|
+
@listener = nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# --- polling backend ---
|
|
152
|
+
|
|
153
|
+
def start_polling
|
|
154
|
+
@thread = Thread.new { watch_loop }
|
|
155
|
+
@thread.name = 'aia-plugin-monitor' if @thread.respond_to?(:name=)
|
|
156
|
+
:polling
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def stop_polling
|
|
160
|
+
begin
|
|
161
|
+
@thread&.wakeup
|
|
162
|
+
rescue ThreadError
|
|
163
|
+
# thread already finished
|
|
164
|
+
end
|
|
165
|
+
@thread&.join(@interval + 0.5)
|
|
166
|
+
@thread = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def watch_loop
|
|
170
|
+
while @running
|
|
171
|
+
begin
|
|
172
|
+
tick
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
@output.puts "Plugin monitor error: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
sleep @interval
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# --- shared ---
|
|
181
|
+
|
|
182
|
+
# @return [Hash{String=>Array}] path => [mtime, size]
|
|
183
|
+
def current_snapshot
|
|
184
|
+
Dir.glob(File.join(@dir, '*.rb')).to_h { |path| [path, file_signature(path)] }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def file_signature(path)
|
|
188
|
+
stat = File.stat(path)
|
|
189
|
+
[stat.mtime, stat.size]
|
|
190
|
+
rescue SystemCallError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Apply a single change by driving the loader, and report it.
|
|
195
|
+
def react(event, path)
|
|
196
|
+
name = File.basename(path, '.rb')
|
|
197
|
+
case event
|
|
198
|
+
when :added
|
|
199
|
+
announce('loaded', name) if @loader.load_file(path, @config)
|
|
200
|
+
when :modified
|
|
201
|
+
announce('reloaded', name) if @loader.load_file(path, @config)
|
|
202
|
+
when :removed
|
|
203
|
+
announce('unloaded', name) if @loader.unload_file(name, @config)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def announce(action, name)
|
|
208
|
+
@output.puts "Plugin #{action}: #{name}"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/prompt_decomposer.rb
|
|
4
|
+
#
|
|
5
|
+
# Decomposes complex prompts into independent sub-tasks.
|
|
6
|
+
# A coordinator robot breaks them down, specialist robots execute,
|
|
7
|
+
# and the coordinator reassembles.
|
|
8
|
+
|
|
9
|
+
require 'json'
|
|
10
|
+
|
|
11
|
+
module AIA
|
|
12
|
+
class PromptDecomposer
|
|
13
|
+
include ContentExtractor
|
|
14
|
+
|
|
15
|
+
DECOMPOSITION_PROMPT = <<~PROMPT
|
|
16
|
+
Analyze this user request and determine if it can be broken into independent sub-tasks.
|
|
17
|
+
|
|
18
|
+
Rules:
|
|
19
|
+
- Only decompose if sub-tasks are truly independent (can run in parallel)
|
|
20
|
+
- Each sub-task should be self-contained
|
|
21
|
+
- 2-5 sub-tasks maximum
|
|
22
|
+
- Keep sub-task descriptions clear and specific
|
|
23
|
+
- Use an empty subtasks array if the request cannot be meaningfully decomposed
|
|
24
|
+
|
|
25
|
+
User request: %{prompt}
|
|
26
|
+
PROMPT
|
|
27
|
+
|
|
28
|
+
# Appended to DECOMPOSITION_PROMPT when the model does not support
|
|
29
|
+
# structured output and with_schema cannot be used.
|
|
30
|
+
FALLBACK_JSON_INSTRUCTION = <<~INSTRUCTION
|
|
31
|
+
|
|
32
|
+
Respond with ONLY a JSON object — no explanation, no markdown, no prose:
|
|
33
|
+
{"subtasks": ["sub-task description 1", "sub-task description 2"]}
|
|
34
|
+
INSTRUCTION
|
|
35
|
+
|
|
36
|
+
# JSON schema for structured-output-capable models.
|
|
37
|
+
# Uses a wrapper object because Claude requires a top-level object.
|
|
38
|
+
SUBTASKS_SCHEMA = {
|
|
39
|
+
name: 'subtask_decomposition',
|
|
40
|
+
schema: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
subtasks: {
|
|
44
|
+
type: 'array',
|
|
45
|
+
items: { type: 'string' },
|
|
46
|
+
description: 'Independent sub-task descriptions. Empty array if not decomposable.'
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
required: ['subtasks'],
|
|
50
|
+
additionalProperties: false
|
|
51
|
+
},
|
|
52
|
+
strict: true
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
SYNTHESIS_TEMPLATE = <<~PROMPT
|
|
56
|
+
Original request: %{prompt}
|
|
57
|
+
|
|
58
|
+
Sub-task results:
|
|
59
|
+
%{results}
|
|
60
|
+
|
|
61
|
+
Synthesize these results into a coherent final response that fully addresses the original request.
|
|
62
|
+
PROMPT
|
|
63
|
+
|
|
64
|
+
def initialize(robot)
|
|
65
|
+
@robot = robot
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Attempt to decompose a prompt into sub-tasks.
|
|
69
|
+
#
|
|
70
|
+
# Uses a temporary probe robot so the decomposition prompt and response do
|
|
71
|
+
# not pollute the main conversation history. When the configured model
|
|
72
|
+
# supports structured output (structured_output? == true) with_schema is
|
|
73
|
+
# used to get a guaranteed Hash back. Otherwise a JSON instruction is
|
|
74
|
+
# appended to the prompt and the text response is parsed manually.
|
|
75
|
+
#
|
|
76
|
+
# @param prompt [String] the user's prompt
|
|
77
|
+
# @return [Array<String>] sub-task descriptions (empty if not decomposable)
|
|
78
|
+
# :reek:TooManyStatements -- schema vs JSON-fallback probe setup, run, extraction, and rescue reporting in one flow
|
|
79
|
+
def decompose(prompt)
|
|
80
|
+
logger = AIA.logger
|
|
81
|
+
probe = build_probe_robot
|
|
82
|
+
use_schema = structured_output?
|
|
83
|
+
|
|
84
|
+
if use_schema
|
|
85
|
+
probe.with_schema(SUBTASKS_SCHEMA)
|
|
86
|
+
full_prompt = DECOMPOSITION_PROMPT % { prompt: prompt }
|
|
87
|
+
else
|
|
88
|
+
logger.warn("PromptDecomposer: configured model does not support structured output — using JSON prompt fallback")
|
|
89
|
+
full_prompt = (DECOMPOSITION_PROMPT + FALLBACK_JSON_INSTRUCTION) % { prompt: prompt }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result = probe.run(full_prompt, mcp: :none, tools: :none)
|
|
93
|
+
content = extract_content(result)
|
|
94
|
+
logger.debug("PromptDecomposer#decompose content class=#{content.class}")
|
|
95
|
+
subtasks = extract_subtasks(content)
|
|
96
|
+
subtasks.is_a?(Array) ? subtasks.select { |t| t.is_a?(String) && !t.empty? } : []
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
logger.warn("PromptDecomposer#decompose failed: #{e.class}: #{e.message}")
|
|
99
|
+
[]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Synthesize sub-task results into a final response.
|
|
103
|
+
#
|
|
104
|
+
# @param prompt [String] the original user prompt
|
|
105
|
+
# @param results [Array<String>] results from each sub-task
|
|
106
|
+
# @return the synthesized response
|
|
107
|
+
def synthesize(prompt, results)
|
|
108
|
+
formatted_results = results.each_with_index.map do |r, i|
|
|
109
|
+
"Sub-task #{i + 1}:\n#{r}"
|
|
110
|
+
end.join("\n\n")
|
|
111
|
+
|
|
112
|
+
@robot.run(
|
|
113
|
+
SYNTHESIS_TEMPLATE % { prompt: prompt, results: formatted_results },
|
|
114
|
+
mcp: :none, tools: :none
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def structured_output?
|
|
121
|
+
RubyLLM.models.find(AIA.config.models.first.internal_id).structured_output?
|
|
122
|
+
rescue StandardError
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Build a fresh temporary robot for the decomposition probe.
|
|
127
|
+
# This robot has no conversation history and is discarded after use,
|
|
128
|
+
# keeping @robot's history clean for the normal or synthesis path.
|
|
129
|
+
def build_probe_robot
|
|
130
|
+
config = AIA.config
|
|
131
|
+
run_config = RobotFactory.build_run_config(config)
|
|
132
|
+
RobotLab.build(
|
|
133
|
+
name: "decompose-probe",
|
|
134
|
+
model: config.models.first.internal_id,
|
|
135
|
+
system_prompt: nil,
|
|
136
|
+
config: run_config
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Extract the subtasks array from the probe response.
|
|
141
|
+
# Hash path: with_schema caused ruby_llm to auto-parse the JSON response.
|
|
142
|
+
# String path: fallback text parsing for models without structured output.
|
|
143
|
+
def extract_subtasks(content)
|
|
144
|
+
case content
|
|
145
|
+
when Hash
|
|
146
|
+
content['subtasks'] || content[:subtasks] || []
|
|
147
|
+
when Array
|
|
148
|
+
content
|
|
149
|
+
when String
|
|
150
|
+
parsed = JSON.parse(content.gsub(/```(?:json)?\s*/i, '').gsub('```', '').strip)
|
|
151
|
+
parsed.is_a?(Hash) ? parsed['subtasks'] || parsed[:subtasks] || [] : parsed
|
|
152
|
+
else
|
|
153
|
+
[]
|
|
154
|
+
end
|
|
155
|
+
rescue JSON::ParserError
|
|
156
|
+
[]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|