aia 1.1.1 → 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 +259 -50
- 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 +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 +304 -164
- 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 +108 -142
- 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 -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
data/lib/aia/crew.rb
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'skill_utils'
|
|
4
|
+
|
|
5
|
+
module AIA
|
|
6
|
+
# Runtime management of the chat session's crew — the Network behind
|
|
7
|
+
# AIA.client. After RobotFactory wraps every session in a crew, members can be
|
|
8
|
+
# added and removed mid-conversation. Both the user (via /add_recruit and
|
|
9
|
+
# /drop_recruit) and a robot (via a tool) funnel through here.
|
|
10
|
+
module Crew
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Recruit a new robot into the running crew. The newcomer is spawned from
|
|
14
|
+
# the crew's chief (sharing its bus), optionally on an explicit
|
|
15
|
+
# model/provider, then added to the crew so it shows in /robots and answers
|
|
16
|
+
# to @name. With no model it inherits the chief's model and provider.
|
|
17
|
+
#
|
|
18
|
+
# @param spec [Hash] {name:, model:, provider:, system_prompt:, skills:} (e.g. from SpawnSpecParser)
|
|
19
|
+
# @return [RobotLab::Robot] the recruited robot
|
|
20
|
+
# @raise [AIA::CrewError]
|
|
21
|
+
def recruit(spec)
|
|
22
|
+
crew = require_crew
|
|
23
|
+
chief = crew.chief
|
|
24
|
+
name = spec[:name].to_s
|
|
25
|
+
if name.casecmp?(MentionRouter::BROADCAST_TOKEN)
|
|
26
|
+
raise CrewError, "'#{name}' is reserved (@crew broadcasts to every member)."
|
|
27
|
+
end
|
|
28
|
+
raise CrewError, "A crewmate named '#{name}' already exists." if member?(crew, name)
|
|
29
|
+
|
|
30
|
+
robot = chief.spawn(
|
|
31
|
+
name: name,
|
|
32
|
+
system_prompt: compose_system_prompt(spec),
|
|
33
|
+
local_tools: chief_local_tools(chief),
|
|
34
|
+
**model_opts(spec)
|
|
35
|
+
)
|
|
36
|
+
inherit_mcp(robot, chief)
|
|
37
|
+
crew.add_robot(robot)
|
|
38
|
+
robot
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reset an existing crewmate to a clean slate and re-skill it. The member is
|
|
42
|
+
# dropped and re-recruited from the chief on the same model/provider, with a
|
|
43
|
+
# fresh conversation and the given skills/system prompt as its new role. The
|
|
44
|
+
# chief cannot be reskilled.
|
|
45
|
+
#
|
|
46
|
+
# @param name [String]
|
|
47
|
+
# @param skills [Array<String>, nil] skill ids to assign as the new role
|
|
48
|
+
# @param system_prompt [String, nil] extra system prompt appended after skills
|
|
49
|
+
# @return [RobotLab::Robot] the re-skilled robot
|
|
50
|
+
# @raise [AIA::CrewError]
|
|
51
|
+
def reskill(name, skills: nil, system_prompt: nil)
|
|
52
|
+
crew = require_crew
|
|
53
|
+
name = name.to_s
|
|
54
|
+
old = find_member(crew, name)
|
|
55
|
+
raise CrewError, "No crewmate named '#{name}'." unless old
|
|
56
|
+
raise CrewError, "Cannot reskill the chief '#{name}'." if crew.chief&.name == name
|
|
57
|
+
|
|
58
|
+
crew.remove_robot(name)
|
|
59
|
+
recruit(
|
|
60
|
+
name: name, model: old.model, provider: old.provider,
|
|
61
|
+
skills: skills, system_prompt: system_prompt
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Drop a crewmate by name. The chief (the session's lead robot) cannot be
|
|
66
|
+
# dropped.
|
|
67
|
+
#
|
|
68
|
+
# @param name [String]
|
|
69
|
+
# @return [RobotLab::Robot] the removed robot
|
|
70
|
+
# @raise [AIA::CrewError]
|
|
71
|
+
def drop(name)
|
|
72
|
+
crew = require_crew
|
|
73
|
+
name = name.to_s
|
|
74
|
+
raise CrewError, "No crewmate named '#{name}'." unless member?(crew, name)
|
|
75
|
+
raise CrewError, "Cannot drop the chief '#{name}'." if crew.chief&.name == name
|
|
76
|
+
|
|
77
|
+
crew.remove_robot(name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The crew (Network) backing the current session.
|
|
81
|
+
#
|
|
82
|
+
# @return [RobotLab::Network]
|
|
83
|
+
# @raise [AIA::CrewError] when the session isn't a crew
|
|
84
|
+
def require_crew
|
|
85
|
+
crew = AIA.client
|
|
86
|
+
raise CrewError, "The active session is not a crew; cannot manage members." unless crew.respond_to?(:add_robot)
|
|
87
|
+
|
|
88
|
+
crew
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def member?(crew, name)
|
|
93
|
+
crew.crew.any? { |robot| robot.name == name }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [RobotLab::Robot, nil]
|
|
97
|
+
def find_member(crew, name)
|
|
98
|
+
crew.crew.find { |robot| robot.name == name }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Build the recruit's system prompt (its role) from any assigned skills
|
|
102
|
+
# followed by an explicit system prompt. Falls back to a simple default when
|
|
103
|
+
# neither is given.
|
|
104
|
+
#
|
|
105
|
+
# @return [String]
|
|
106
|
+
def compose_system_prompt(spec)
|
|
107
|
+
parts = [skills_prompt(spec[:skills]), spec[:system_prompt]].compact
|
|
108
|
+
parts.empty? ? "You are #{spec[:name]}." : parts.join("\n\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Load the bodies of the named skills, joined, to seed a recruit's role.
|
|
112
|
+
# Raises so the caller (user or chief) hears about an unknown skill rather
|
|
113
|
+
# than silently recruiting a robot without the role it was meant to have.
|
|
114
|
+
#
|
|
115
|
+
# @param skill_ids [Array<String>, nil]
|
|
116
|
+
# @return [String, nil]
|
|
117
|
+
# @raise [AIA::CrewError]
|
|
118
|
+
def skills_prompt(skill_ids)
|
|
119
|
+
ids = Array(skill_ids).map(&:to_s).reject(&:empty?)
|
|
120
|
+
return nil if ids.empty?
|
|
121
|
+
|
|
122
|
+
base = AIA::SkillUtils.skills_base_dir(AIA.config)
|
|
123
|
+
raise CrewError, "No skills directory is configured." unless base
|
|
124
|
+
|
|
125
|
+
missing = ids.reject { |id| AIA::SkillUtils.find_skill_dir(id, base) }
|
|
126
|
+
raise CrewError, "Skill(s) not found: #{missing.join(', ')}." unless missing.empty?
|
|
127
|
+
|
|
128
|
+
AIA::SkillUtils.load_skills_content(ids, base)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Model/provider override for an explicit recruit. When a model is given the
|
|
132
|
+
# provider is passed too (even nil) so it overrides the inherited chief
|
|
133
|
+
# provider; with no model, {} lets the spawn inherit chief model + provider.
|
|
134
|
+
#
|
|
135
|
+
# @return [Hash]
|
|
136
|
+
def model_opts(spec)
|
|
137
|
+
return {} unless spec[:model]
|
|
138
|
+
|
|
139
|
+
{ model: spec[:model], provider: spec[:provider] }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The chief's local tool instances, shared with the recruit so it can do the
|
|
143
|
+
# same work (file/shell/etc.). Tool instances are effectively stateless, so
|
|
144
|
+
# sharing them across crew members is safe.
|
|
145
|
+
#
|
|
146
|
+
# @return [Array]
|
|
147
|
+
def chief_local_tools(chief)
|
|
148
|
+
chief.respond_to?(:local_tools) ? Array(chief.local_tools) : []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Hand the recruit the chief's already-connected MCP clients and tools rather
|
|
152
|
+
# than opening fresh connections.
|
|
153
|
+
#
|
|
154
|
+
# @return [void]
|
|
155
|
+
def inherit_mcp(robot, chief)
|
|
156
|
+
return unless robot.respond_to?(:inject_mcp!) && chief.respond_to?(:mcp_clients)
|
|
157
|
+
|
|
158
|
+
clients = chief.mcp_clients
|
|
159
|
+
return if clients.nil? || clients.empty?
|
|
160
|
+
|
|
161
|
+
robot.inject_mcp!(clients: clients, tools: Array(chief.mcp_tools))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/debate_handler.rb
|
|
4
|
+
#
|
|
5
|
+
# Multi-round debate between robots in a network.
|
|
6
|
+
# Each robot responds to the previous round's outputs until
|
|
7
|
+
# convergence is reached or a round limit is hit.
|
|
8
|
+
# Uses TypedBus for bus attachment and SharedMemory for state.
|
|
9
|
+
# Uses Async::Barrier for concurrent robot execution per round.
|
|
10
|
+
|
|
11
|
+
require 'async'
|
|
12
|
+
|
|
13
|
+
module AIA
|
|
14
|
+
class DebateHandler
|
|
15
|
+
include ContentExtractor
|
|
16
|
+
include HandlerProtocol
|
|
17
|
+
|
|
18
|
+
MAX_ROUNDS = 5
|
|
19
|
+
MIN_ROUNDS = 2
|
|
20
|
+
SIMILARITY_THRESHOLD = 0.85
|
|
21
|
+
CONVERGENCE_SIGNAL = "CONVERGED"
|
|
22
|
+
|
|
23
|
+
# Sentinel value for a robot that failed to respond in a round.
|
|
24
|
+
# Treated as empty string for convergence; rendered with [FAILED] marker.
|
|
25
|
+
FailedResponse = Data.define(:robot_name, :error_message)
|
|
26
|
+
|
|
27
|
+
def initialize(robot:, ui_presenter:, tracker:)
|
|
28
|
+
@robot = robot
|
|
29
|
+
@ui_presenter = ui_presenter
|
|
30
|
+
@tracker = tracker
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_writer :robot
|
|
34
|
+
|
|
35
|
+
# Run a debate between robots in the network.
|
|
36
|
+
#
|
|
37
|
+
# @param context [HandlerContext] — reads context.prompt
|
|
38
|
+
# @return [String, nil] formatted debate results, or nil if not applicable
|
|
39
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
40
|
+
def handle(context)
|
|
41
|
+
prompt = context.prompt
|
|
42
|
+
return nil unless @robot.network?
|
|
43
|
+
|
|
44
|
+
robots = @robot.crew
|
|
45
|
+
return nil if robots.size < 2
|
|
46
|
+
|
|
47
|
+
# Ensure all robots share a bus
|
|
48
|
+
bus = TypedBus::MessageBus.new
|
|
49
|
+
robots.each { |r| r.with_bus(bus) }
|
|
50
|
+
|
|
51
|
+
@ui_presenter.display_info(
|
|
52
|
+
"Debate between #{robots.map(&:name).join(', ')}..."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
rounds = []
|
|
56
|
+
|
|
57
|
+
MAX_ROUNDS.times do |round|
|
|
58
|
+
round_context = build_round_context(prompt, rounds)
|
|
59
|
+
|
|
60
|
+
round_results = Sync do
|
|
61
|
+
barrier = Async::Barrier.new
|
|
62
|
+
tasks = robots.map do |robot|
|
|
63
|
+
barrier.async do
|
|
64
|
+
@ui_presenter.display_info(" Round #{round + 1}: #{robot.name}...")
|
|
65
|
+
result = robot.run(round_context, mcp: :inherit, tools: :inherit)
|
|
66
|
+
content = extract_content(result)
|
|
67
|
+
write_to_memory(round, robot.name, content)
|
|
68
|
+
{ robot: robot.name, content: content }
|
|
69
|
+
rescue => e
|
|
70
|
+
FailedResponse.new(robot_name: robot.name, error_message: e.message)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
barrier.wait
|
|
74
|
+
tasks.map(&:wait)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
raise DebateError, "All robots failed in round #{round + 1}" if
|
|
78
|
+
round_results.all?(FailedResponse)
|
|
79
|
+
|
|
80
|
+
previous = rounds.last
|
|
81
|
+
rounds << round_results
|
|
82
|
+
|
|
83
|
+
if converged?(round, round_results, previous)
|
|
84
|
+
@ui_presenter.display_info(" Converged in round #{round + 1}.")
|
|
85
|
+
break
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@tracker.record_turn(
|
|
90
|
+
model: AIA.config.models.first.name,
|
|
91
|
+
input: prompt,
|
|
92
|
+
result: rounds
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
format_rounds(rounds)
|
|
96
|
+
end
|
|
97
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def build_round_context(prompt, rounds)
|
|
102
|
+
return prompt if rounds.empty?
|
|
103
|
+
|
|
104
|
+
previous = rounds.last.map do |r|
|
|
105
|
+
if r.is_a?(FailedResponse)
|
|
106
|
+
"#{r.robot_name}: [FAILED: #{r.error_message}]"
|
|
107
|
+
else
|
|
108
|
+
"#{r[:robot]}: #{r[:content]}"
|
|
109
|
+
end
|
|
110
|
+
end.join("\n\n")
|
|
111
|
+
|
|
112
|
+
<<~CONTEXT
|
|
113
|
+
Topic: #{prompt}
|
|
114
|
+
|
|
115
|
+
Previous round:
|
|
116
|
+
#{previous}
|
|
117
|
+
|
|
118
|
+
Respond to the points above. Say CONVERGED if you agree with the overall consensus and provide a final summary. Otherwise refine your position.
|
|
119
|
+
CONTEXT
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def converged?(round_index, current_results, previous_results)
|
|
123
|
+
return false if round_index < MIN_ROUNDS - 1
|
|
124
|
+
|
|
125
|
+
# Fast path: every robot explicitly signaled convergence.
|
|
126
|
+
return true if all_signaled_convergence?(current_results)
|
|
127
|
+
|
|
128
|
+
return false if previous_results.nil?
|
|
129
|
+
|
|
130
|
+
current_text = current_results.map { |r| r.is_a?(FailedResponse) ? "" : r[:content].to_s }.join(" ")
|
|
131
|
+
previous_text = previous_results.map { |r| r.is_a?(FailedResponse) ? "" : r[:content].to_s }.join(" ")
|
|
132
|
+
|
|
133
|
+
scores = SimilarityScorer.score([previous_text, current_text])
|
|
134
|
+
(scores[1] || 0.0) >= SIMILARITY_THRESHOLD
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def all_signaled_convergence?(round_results)
|
|
138
|
+
round_results.all? do |r|
|
|
139
|
+
next false if r.is_a?(FailedResponse)
|
|
140
|
+
r[:content].to_s.upcase.include?(CONVERGENCE_SIGNAL)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def write_to_memory(round, robot_name, content)
|
|
145
|
+
return unless @robot.respond_to?(:memory)
|
|
146
|
+
|
|
147
|
+
@robot.memory.current_writer = robot_name
|
|
148
|
+
@robot.memory.set(:"debate_r#{round}_#{robot_name}", content)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def format_rounds(rounds)
|
|
152
|
+
lines = []
|
|
153
|
+
rounds.each_with_index do |round, i|
|
|
154
|
+
lines << "### Round #{i + 1}"
|
|
155
|
+
round.each do |entry|
|
|
156
|
+
lines << if entry.is_a?(FailedResponse)
|
|
157
|
+
"**#{entry.robot_name}**: [FAILED] #{entry.error_message}\n"
|
|
158
|
+
else
|
|
159
|
+
"**#{entry[:robot]}**: #{entry[:content]}\n"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
lines.join("\n")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/delegate_handler.rb
|
|
4
|
+
#
|
|
5
|
+
# Lead robot analyzes a prompt, creates a TrakFlow plan with steps
|
|
6
|
+
# assigned to specific robots, then each robot executes its tasks
|
|
7
|
+
# in dependency order. Combines TypedBus, SharedMemory, and TrakFlow.
|
|
8
|
+
#
|
|
9
|
+
# Decomposition is delegated to TaskDecomposer.
|
|
10
|
+
# Step execution is delegated to TaskExecutor.
|
|
11
|
+
|
|
12
|
+
module AIA
|
|
13
|
+
class DelegateHandler
|
|
14
|
+
include ContentExtractor
|
|
15
|
+
include HandlerProtocol
|
|
16
|
+
|
|
17
|
+
def initialize(robot:, ui_presenter:, tracker:, task_coordinator:)
|
|
18
|
+
@robot = robot
|
|
19
|
+
@ui_presenter = ui_presenter
|
|
20
|
+
@tracker = tracker
|
|
21
|
+
@task_coordinator = task_coordinator
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_writer :robot
|
|
25
|
+
|
|
26
|
+
# Decompose a prompt into subtasks, assign to robots, and execute.
|
|
27
|
+
#
|
|
28
|
+
# @param context [HandlerContext] — reads context.prompt
|
|
29
|
+
# @return [String, nil] combined results, or nil if not applicable
|
|
30
|
+
def handle(context)
|
|
31
|
+
prompt = context.prompt
|
|
32
|
+
return nil unless @robot.network?
|
|
33
|
+
return nil unless @task_coordinator&.available?
|
|
34
|
+
|
|
35
|
+
robots = @robot.robots
|
|
36
|
+
robot_names = robots.values.map(&:name)
|
|
37
|
+
lead = robots.values.first
|
|
38
|
+
|
|
39
|
+
# Step 1: Decompose the work
|
|
40
|
+
decomposer = TaskDecomposer.new(lead_robot: lead, ui_presenter: @ui_presenter)
|
|
41
|
+
steps = decomposer.decompose(prompt, robot_names)
|
|
42
|
+
|
|
43
|
+
if steps.empty?
|
|
44
|
+
@ui_presenter.display_info("Could not decompose into subtasks.")
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Step 2: Create TrakFlow plan
|
|
49
|
+
plan = @task_coordinator.create_plan(
|
|
50
|
+
"Delegated: #{prompt[0, 60]}",
|
|
51
|
+
steps: steps, creator: lead.name, ephemeral: true
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@ui_presenter.display_info("Plan with #{steps.size} tasks:")
|
|
55
|
+
steps.each_with_index do |s, i|
|
|
56
|
+
@ui_presenter.display_info(" #{i + 1}. #{s[:title]} -> #{s[:assignee]}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Step 3: Execute tasks in order
|
|
60
|
+
executor = TaskExecutor.new(task_coordinator: @task_coordinator)
|
|
61
|
+
results = execute_steps(executor, prompt, plan, steps, robots)
|
|
62
|
+
|
|
63
|
+
@tracker.record_turn(
|
|
64
|
+
model: AIA.config.models.first.name,
|
|
65
|
+
input: prompt,
|
|
66
|
+
result: results
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
format_results(results)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def execute_steps(executor, prompt, plan, steps, robots)
|
|
75
|
+
results = []
|
|
76
|
+
|
|
77
|
+
plan[:steps].each_with_index do |task, i|
|
|
78
|
+
step_def = steps[i]
|
|
79
|
+
assignee = robots.values.find { |r| r.name == step_def[:assignee] }
|
|
80
|
+
assignee ||= robots.values.first
|
|
81
|
+
|
|
82
|
+
@ui_presenter.display_info(" #{assignee.name}: #{step_def[:title]}...")
|
|
83
|
+
|
|
84
|
+
content = executor.execute(task, assignee, step_def, prompt, results)
|
|
85
|
+
|
|
86
|
+
write_to_memory(i, assignee.name, step_def[:title], content)
|
|
87
|
+
results << { robot: assignee.name, task: step_def[:title], content: content }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
results
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def write_to_memory(index, robot_name, task_title, content)
|
|
94
|
+
return unless @robot.respond_to?(:memory)
|
|
95
|
+
|
|
96
|
+
@robot.memory.current_writer = robot_name
|
|
97
|
+
@robot.memory.set(:"delegate_step_#{index}", {
|
|
98
|
+
robot: robot_name, task: task_title, content: content
|
|
99
|
+
})
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def format_results(results)
|
|
103
|
+
lines = []
|
|
104
|
+
results.each_with_index do |r, i|
|
|
105
|
+
lines << "### Step #{i + 1}: #{r[:task]} (#{r[:robot]})"
|
|
106
|
+
lines << r[:content]
|
|
107
|
+
lines << ""
|
|
108
|
+
end
|
|
109
|
+
lines.join("\n")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/aia/directive.rb
CHANGED
|
@@ -34,9 +34,28 @@
|
|
|
34
34
|
|
|
35
35
|
module AIA
|
|
36
36
|
class Directive < PM::Directive
|
|
37
|
-
DIRECTIVE_PREFIX = '/'
|
|
37
|
+
DIRECTIVE_PREFIX = '/'.freeze
|
|
38
38
|
|
|
39
39
|
class << self
|
|
40
|
+
# ---- State-setting DSL -----------------------------------------------
|
|
41
|
+
# Directives that mutate turn-state or set a mode flag (rather than
|
|
42
|
+
# returning text for the AI) should declare themselves here so that
|
|
43
|
+
# ChatLoop can ask the processor instead of hardcoding a name list.
|
|
44
|
+
#
|
|
45
|
+
# Usage (in a subclass body):
|
|
46
|
+
# state_setting! :verify, :decompose, :concurrent, :conc
|
|
47
|
+
#
|
|
48
|
+
# The names must match the directive method names (without the / prefix).
|
|
49
|
+
|
|
50
|
+
def state_setting!(*method_names)
|
|
51
|
+
@state_setting_methods ||= []
|
|
52
|
+
@state_setting_methods.concat(method_names.map(&:to_s))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def state_setting_methods
|
|
56
|
+
@state_setting_methods || []
|
|
57
|
+
end
|
|
58
|
+
|
|
40
59
|
# ---- Dispatch override ------------------------------------------------
|
|
41
60
|
# AIA directive methods use (args_array, context_manager) convention,
|
|
42
61
|
# not (ctx, *args). Override build_dispatch_block to adapt.
|
|
@@ -87,27 +106,23 @@ module AIA
|
|
|
87
106
|
|
|
88
107
|
private
|
|
89
108
|
|
|
90
|
-
# Split args into
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
# All terms are
|
|
94
|
-
#
|
|
95
|
-
# @param args [Array<String>] raw argument tokens
|
|
96
|
-
# @return [Array<Array<String>>] [positive_terms, negative_terms]
|
|
109
|
+
# Split raw args into [positive_terms, negative_terms].
|
|
110
|
+
# Leading '+' is stripped; the term is treated as positive.
|
|
111
|
+
# Leading '-', '~', or '!' marks the term as negative (AND NOT).
|
|
112
|
+
# Bare terms are positive. All terms are lowercased.
|
|
97
113
|
def parse_search_terms(args)
|
|
98
114
|
positive = []
|
|
99
115
|
negative = []
|
|
100
116
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
end
|
|
117
|
+
args.flat_map { |a| a.to_s.split }.each do |raw|
|
|
118
|
+
if raw.start_with?('+')
|
|
119
|
+
term = raw[1..].downcase
|
|
120
|
+
positive << term unless term.empty?
|
|
121
|
+
elsif raw.start_with?('-', '~', '!')
|
|
122
|
+
term = raw[1..].downcase
|
|
123
|
+
negative << term unless term.empty?
|
|
124
|
+
else
|
|
125
|
+
positive << raw.downcase
|
|
111
126
|
end
|
|
112
127
|
end
|
|
113
128
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'word_wrapper'
|
|
5
|
-
require 'set'
|
|
6
5
|
require_relative 'directive'
|
|
7
6
|
require_relative 'directives/configuration_directives'
|
|
8
7
|
require_relative 'directives/context_directives'
|
|
@@ -10,6 +9,7 @@ require_relative 'directives/execution_directives'
|
|
|
10
9
|
require_relative 'directives/utility_directives'
|
|
11
10
|
require_relative 'directives/web_and_file_directives'
|
|
12
11
|
require_relative 'directives/model_directives'
|
|
12
|
+
require_relative 'directives/trakflow_directives'
|
|
13
13
|
|
|
14
14
|
module AIA
|
|
15
15
|
class DirectiveProcessor
|
|
@@ -19,7 +19,6 @@ module AIA
|
|
|
19
19
|
@prefix_size = DIRECTIVE_PREFIX.size
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
23
22
|
# Checks whether a string looks like a chat-time directive.
|
|
24
23
|
# Uses PM.directives as the source of truth for known directive names.
|
|
25
24
|
def directive?(string)
|
|
@@ -30,12 +29,21 @@ module AIA
|
|
|
30
29
|
|
|
31
30
|
# Extract the directive name and check it's registered
|
|
32
31
|
sans_prefix = stripped[@prefix_size..]
|
|
33
|
-
method_name = sans_prefix.split
|
|
32
|
+
method_name = sans_prefix.split.first&.downcase
|
|
34
33
|
return false if method_name.nil? || method_name.empty?
|
|
35
34
|
|
|
36
35
|
PM.directives.key?(method_name.to_sym)
|
|
37
36
|
end
|
|
38
37
|
|
|
38
|
+
# Returns true when the directive mutates turn-state or sets a mode flag
|
|
39
|
+
# rather than returning text for the AI. ChatLoop uses this to decide
|
|
40
|
+
# whether to skip forwarding the directive output to the robot.
|
|
41
|
+
def state_setting?(input)
|
|
42
|
+
name = extract_content(input).strip.split.first.to_s.delete_prefix('/')
|
|
43
|
+
AIA::Directive.subclasses.any? do |klass|
|
|
44
|
+
klass.state_setting_methods.include?(name)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
39
47
|
|
|
40
48
|
# Process a chat-time directive by dispatching through PM.directives.
|
|
41
49
|
# Returns the block's return value: non-blank string for content directives,
|
|
@@ -46,7 +54,7 @@ module AIA
|
|
|
46
54
|
content = extract_content(string)
|
|
47
55
|
key = content.strip
|
|
48
56
|
sans_prefix = key[@prefix_size..]
|
|
49
|
-
args = sans_prefix.split
|
|
57
|
+
args = sans_prefix.split
|
|
50
58
|
method_name = args.shift.downcase
|
|
51
59
|
|
|
52
60
|
block = PM.directives[method_name.to_sym]
|
|
@@ -58,7 +66,6 @@ module AIA
|
|
|
58
66
|
block.call(render_context, *args)
|
|
59
67
|
end
|
|
60
68
|
|
|
61
|
-
|
|
62
69
|
private
|
|
63
70
|
|
|
64
71
|
def render_context
|
|
@@ -67,17 +74,19 @@ module AIA
|
|
|
67
74
|
params: {},
|
|
68
75
|
included: Set.new,
|
|
69
76
|
depth: 0,
|
|
70
|
-
metadata:
|
|
77
|
+
metadata: Struct.new(:includes).new([])
|
|
71
78
|
)
|
|
72
79
|
end
|
|
73
80
|
|
|
74
81
|
def extract_content(string)
|
|
75
|
-
if string.is_a?(RubyLLM::Message)
|
|
82
|
+
if defined?(RubyLLM::Message) && string.is_a?(RubyLLM::Message)
|
|
76
83
|
begin
|
|
77
84
|
string.content
|
|
78
85
|
rescue StandardError
|
|
79
86
|
string.to_s
|
|
80
87
|
end
|
|
88
|
+
elsif string.respond_to?(:content)
|
|
89
|
+
string.content.to_s
|
|
81
90
|
else
|
|
82
91
|
string.to_s
|
|
83
92
|
end
|