phronomy 0.3.0 → 0.5.0
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/CHANGELOG.md +83 -0
- data/README.md +85 -16
- data/lib/phronomy/agent/base.rb +117 -382
- data/lib/phronomy/agent/checkpoint.rb +12 -5
- data/lib/phronomy/agent/concerns/before_completion.rb +105 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +42 -0
- data/lib/phronomy/agent/concerns/retryable.rb +88 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +116 -0
- data/lib/phronomy/agent/orchestrator.rb +119 -0
- data/lib/phronomy/agent/react_agent.rb +37 -16
- data/lib/phronomy/agent/shared_state.rb +303 -0
- data/lib/phronomy/agent/team_coordinator.rb +285 -0
- data/lib/phronomy/{trust_pipeline.rb → generator_verifier.rb} +95 -108
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +41 -22
- data/lib/phronomy.rb +17 -0
- metadata +12 -6
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Implements the "Shared state" coordination pattern (Anthropic blog, Pattern 5).
|
|
6
|
+
#
|
|
7
|
+
# @see https://claude.com/blog/multi-agent-coordination-patterns
|
|
8
|
+
#
|
|
9
|
+
# Multiple peer agents collaborate through a shared {KnowledgeStore}.
|
|
10
|
+
# There is no central coordinator. Each agent reads the store, acts on what it
|
|
11
|
+
# finds, and writes new findings back. Later agents in a cycle immediately see
|
|
12
|
+
# findings written by earlier agents in the same cycle.
|
|
13
|
+
#
|
|
14
|
+
# Two tools are automatically injected into every member agent at runtime:
|
|
15
|
+
# - +read_store+ — returns all current findings as a JSON string
|
|
16
|
+
# - +write_finding+ — appends a Hash finding to the store
|
|
17
|
+
#
|
|
18
|
+
# Use +member+ to register agents and optionally provide per-agent coordination
|
|
19
|
+
# instructions. Use +coordination+ to define the team-level protocol that all
|
|
20
|
+
# members receive instead of the built-in default guide.
|
|
21
|
+
#
|
|
22
|
+
# @example Basic usage with per-agent instructions
|
|
23
|
+
# class CodeReviewTeam < Phronomy::Agent::SharedState
|
|
24
|
+
# member StructureAnalyst
|
|
25
|
+
# member SecurityAuditor, instruction: "Focus on authentication and injection risks."
|
|
26
|
+
# member QualityReviewer, instruction: "Flag methods longer than 10 lines."
|
|
27
|
+
# max_cycles 3
|
|
28
|
+
# aggregate { |store| { findings: store.read_all, total: store.size } }
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# result = CodeReviewTeam.new.invoke("Review the files in ./src")
|
|
32
|
+
# # => { output: { findings: [...], total: N }, cycles: 3, terminated_by: :max_cycles }
|
|
33
|
+
#
|
|
34
|
+
# @example With custom team-level coordination protocol
|
|
35
|
+
# class ResearchTeam < Phronomy::Agent::SharedState
|
|
36
|
+
# coordination <<~TEXT
|
|
37
|
+
# Shared store tools: read_store (no params), write_finding(content:).
|
|
38
|
+
# Workflow: read first, then write one finding per insight.
|
|
39
|
+
# TEXT
|
|
40
|
+
# member LiteratureAgent
|
|
41
|
+
# member IndustryAgent
|
|
42
|
+
# max_cycles 10
|
|
43
|
+
# terminate_when { |store| store.size >= 20 }
|
|
44
|
+
# end
|
|
45
|
+
class SharedState
|
|
46
|
+
# Thread-safe (serialised by sequential execution) knowledge store shared
|
|
47
|
+
# across all researcher agents within a single {SharedState#invoke} call.
|
|
48
|
+
#
|
|
49
|
+
# Each finding is stored as a Hash with the keys:
|
|
50
|
+
# :agent — Symbol derived from the researcher class name
|
|
51
|
+
# :content — String written by the researcher
|
|
52
|
+
# :cycle — Integer cycle number in which the finding was recorded
|
|
53
|
+
class KnowledgeStore
|
|
54
|
+
def initialize
|
|
55
|
+
@findings = []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns a shallow copy of all findings in insertion order.
|
|
59
|
+
# @return [Array<Hash>]
|
|
60
|
+
def read_all
|
|
61
|
+
@findings.dup
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Appends a new finding to the store.
|
|
65
|
+
# @param agent [Symbol] researcher identifier
|
|
66
|
+
# @param content [String] the finding text
|
|
67
|
+
# @param cycle [Integer] the current cycle number
|
|
68
|
+
# @return [nil]
|
|
69
|
+
def write(agent:, content:, cycle:)
|
|
70
|
+
@findings << {agent: agent, content: content, cycle: cycle}
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the number of findings recorded so far.
|
|
75
|
+
# @return [Integer]
|
|
76
|
+
def size
|
|
77
|
+
@findings.size
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
# Registers a member agent class that will collaborate via the shared store.
|
|
83
|
+
# Members are invoked sequentially within each cycle in declaration order.
|
|
84
|
+
#
|
|
85
|
+
# @param klass [Class] an Agent::Base subclass
|
|
86
|
+
# @param instruction [String, nil] optional per-agent coordination instruction
|
|
87
|
+
# appended to the team coordination text in this agent's prompt
|
|
88
|
+
def member(klass, instruction: nil)
|
|
89
|
+
@members ||= []
|
|
90
|
+
@members << {klass: klass, instruction: instruction}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Backward-compatible alias. Registers each class as a member without a
|
|
94
|
+
# per-agent instruction. Prefer {.member} for new code.
|
|
95
|
+
#
|
|
96
|
+
# @param classes [Array<Class>] Agent::Base subclasses
|
|
97
|
+
def researchers(*classes)
|
|
98
|
+
classes.flatten.each { |klass| member(klass) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Defines the team-level coordination protocol text injected into every
|
|
102
|
+
# member's prompt. When omitted the built-in default guide is used, which
|
|
103
|
+
# explains +read_store+ / +write_finding+ usage and enforces the standard
|
|
104
|
+
# workflow. Override this when you need a different protocol or tone.
|
|
105
|
+
#
|
|
106
|
+
# @param text [String, nil] the coordination instructions
|
|
107
|
+
def coordination(text = nil)
|
|
108
|
+
text ? @coordination = text : @coordination
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Sets the maximum number of cycles to run.
|
|
112
|
+
# At least one of +max_cycles+ or +timeout+ must be configured.
|
|
113
|
+
#
|
|
114
|
+
# @param value [Integer, nil]
|
|
115
|
+
def max_cycles(value = nil)
|
|
116
|
+
value ? @max_cycles = Integer(value) : @max_cycles
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Sets the maximum wall-clock seconds for the entire invocation.
|
|
120
|
+
# At least one of +max_cycles+ or +timeout+ must be configured.
|
|
121
|
+
#
|
|
122
|
+
# @param value [Numeric, nil]
|
|
123
|
+
def timeout(value = nil)
|
|
124
|
+
value ? @timeout = value.to_f : @timeout
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Registers an optional convergence block. Evaluated after each completed
|
|
128
|
+
# cycle; when it returns +true+ the loop terminates early.
|
|
129
|
+
#
|
|
130
|
+
# @yield [KnowledgeStore] receives the store; return +true+ to stop
|
|
131
|
+
def terminate_when(&block)
|
|
132
|
+
block ? @terminate_when = block : @terminate_when
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Defines how the final store is converted into the +:output+ of the result.
|
|
136
|
+
# When omitted, +store.read_all+ is used as-is.
|
|
137
|
+
#
|
|
138
|
+
# @yield [KnowledgeStore] receives the final store; return value becomes +:output+
|
|
139
|
+
def aggregate(&block)
|
|
140
|
+
block ? @aggregator = block : @aggregator
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @!visibility private
|
|
144
|
+
def _members = Array(@members)
|
|
145
|
+
# @!visibility private — derives class list from _members for backward compat
|
|
146
|
+
def _researchers = _members.map { |m| m[:klass] }
|
|
147
|
+
# @!visibility private
|
|
148
|
+
def _coordination = @coordination
|
|
149
|
+
# @!visibility private
|
|
150
|
+
def _max_cycles = @max_cycles
|
|
151
|
+
# @!visibility private
|
|
152
|
+
def _timeout = @timeout
|
|
153
|
+
# @!visibility private
|
|
154
|
+
def _terminate_when = @terminate_when
|
|
155
|
+
# @!visibility private
|
|
156
|
+
def _aggregator = @aggregator
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Runs the shared-state coordination loop.
|
|
160
|
+
#
|
|
161
|
+
# @param input [String] the seed question or task description
|
|
162
|
+
# @param config [Hash] reserved for future use
|
|
163
|
+
# @return [Hash] +:output+, +:cycles+, +:terminated_by+
|
|
164
|
+
# @raise [ArgumentError] when neither +max_cycles+ nor +timeout+ is configured
|
|
165
|
+
def invoke(input, config: {})
|
|
166
|
+
validate_termination!
|
|
167
|
+
|
|
168
|
+
store = KnowledgeStore.new
|
|
169
|
+
max_cycles = self.class._max_cycles
|
|
170
|
+
deadline = self.class._timeout ? Time.now + self.class._timeout : nil
|
|
171
|
+
terminated_by = :max_cycles
|
|
172
|
+
completed_cycles = 0
|
|
173
|
+
|
|
174
|
+
cycle_limit = max_cycles || Float::INFINITY
|
|
175
|
+
|
|
176
|
+
(1..cycle_limit).each do |cycle|
|
|
177
|
+
self.class._members.each do |member_config|
|
|
178
|
+
invoke_researcher(member_config[:klass], store, cycle, input, member_config[:instruction])
|
|
179
|
+
end
|
|
180
|
+
completed_cycles = cycle
|
|
181
|
+
|
|
182
|
+
if self.class._terminate_when&.call(store)
|
|
183
|
+
terminated_by = :terminate_when
|
|
184
|
+
break
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if deadline && Time.now >= deadline
|
|
188
|
+
terminated_by = :timeout
|
|
189
|
+
break
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
terminated_by = :max_cycles
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
output = if self.class._aggregator
|
|
196
|
+
self.class._aggregator.call(store)
|
|
197
|
+
else
|
|
198
|
+
store.read_all
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
{output: output, cycles: completed_cycles, terminated_by: terminated_by}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def validate_termination!
|
|
207
|
+
return if self.class._max_cycles || self.class._timeout
|
|
208
|
+
raise ArgumentError,
|
|
209
|
+
"max_cycles or timeout must be configured before invoking SharedState"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Invokes a single member agent for one cycle.
|
|
213
|
+
# Builds an anonymous subclass with +read_store+ and +write_finding+ injected,
|
|
214
|
+
# then calls +invoke+ with a prompt that includes the coordination guide,
|
|
215
|
+
# any per-agent instruction, and the current store contents.
|
|
216
|
+
def invoke_researcher(researcher_class, store, cycle, original_input, per_agent_instruction = nil)
|
|
217
|
+
instrumented = build_instrumented_researcher(researcher_class, store, cycle)
|
|
218
|
+
extra_tools = researcher_class.tools
|
|
219
|
+
prompt = build_prompt(original_input, store, cycle,
|
|
220
|
+
extra_tools: extra_tools,
|
|
221
|
+
per_agent_instruction: per_agent_instruction)
|
|
222
|
+
instrumented.new.invoke(prompt)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Builds an anonymous subclass of +researcher_class+ with two store tools
|
|
226
|
+
# injected. The tools close over the +store+ instance so that writes and
|
|
227
|
+
# reads are reflected in the live store.
|
|
228
|
+
def build_instrumented_researcher(researcher_class, store, cycle)
|
|
229
|
+
agent_key = researcher_class.name&.to_sym || researcher_class.object_id.to_s.to_sym
|
|
230
|
+
|
|
231
|
+
read_tool = Class.new(Phronomy::Tool::Base) do
|
|
232
|
+
tool_name "read_store"
|
|
233
|
+
description "Read all current findings from the shared knowledge store. " \
|
|
234
|
+
"Call this to see what other researchers have discovered."
|
|
235
|
+
|
|
236
|
+
define_method(:execute) { store.read_all.to_json }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
write_tool = Class.new(Phronomy::Tool::Base) do
|
|
240
|
+
tool_name "write_finding"
|
|
241
|
+
description "Record a new finding into the shared knowledge store so " \
|
|
242
|
+
"that other researchers can build on your discovery."
|
|
243
|
+
param :content, type: :string, desc: "The finding to record"
|
|
244
|
+
|
|
245
|
+
define_method(:execute) do |content:|
|
|
246
|
+
store.write(agent: agent_key, content: content, cycle: cycle)
|
|
247
|
+
"Finding recorded."
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
parent_tools = researcher_class.tools
|
|
252
|
+
Class.new(researcher_class) { tools(*parent_tools, read_tool, write_tool) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Builds the invocation prompt for a member agent.
|
|
256
|
+
# Uses the team-level coordination text when defined via {.coordination},
|
|
257
|
+
# otherwise falls back to the built-in default guide. Appends any per-agent
|
|
258
|
+
# instruction after the coordination text. Subsequent cycles also include
|
|
259
|
+
# the current store contents so agents can build on prior findings.
|
|
260
|
+
def build_prompt(original_input, store, cycle, extra_tools: [], per_agent_instruction: nil)
|
|
261
|
+
guide = self.class._coordination || default_coordination_guide(extra_tools)
|
|
262
|
+
|
|
263
|
+
prompt_parts = [guide]
|
|
264
|
+
if per_agent_instruction
|
|
265
|
+
prompt_parts << "\nYour specific focus for this session: #{per_agent_instruction}"
|
|
266
|
+
end
|
|
267
|
+
header = prompt_parts.join
|
|
268
|
+
|
|
269
|
+
base = "#{header}\n\nTask: #{original_input}"
|
|
270
|
+
return base if store.size == 0
|
|
271
|
+
|
|
272
|
+
findings_text = store.read_all
|
|
273
|
+
.map { |f| "- [#{f[:agent]} / cycle #{f[:cycle]}] #{f[:content]}" }
|
|
274
|
+
.join("\n")
|
|
275
|
+
|
|
276
|
+
"#{header}\n\nTask: #{original_input}\n\nFindings so far:\n#{findings_text}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Builds the default tool-usage guide for member agents.
|
|
280
|
+
# Describes +read_store+ / +write_finding+ and the required workflow.
|
|
281
|
+
# When extra_tools are present, lists their names so the agent knows
|
|
282
|
+
# what additional tools are available.
|
|
283
|
+
def default_coordination_guide(extra_tools)
|
|
284
|
+
extra_line = if extra_tools.any?
|
|
285
|
+
tool_names = extra_tools.map { |t| t.respond_to?(:tool_name) ? t.tool_name : t.name.to_s }.join(", ")
|
|
286
|
+
" You also have access to additional tools (#{tool_names}) — use them to gather information before writing findings.\n"
|
|
287
|
+
else
|
|
288
|
+
""
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
<<~TEXT.chomp
|
|
292
|
+
You have access to a shared knowledge store via two tools:
|
|
293
|
+
read_store — returns all current findings as JSON (no parameters)
|
|
294
|
+
write_finding — records one finding to the store (param: content)
|
|
295
|
+
#{extra_line}Required workflow: first call read_store, then call write_finding once per insight.
|
|
296
|
+
Each call to write_finding must contain exactly one unique insight — do not call it twice with the same content.
|
|
297
|
+
If you have no new insights to contribute, call write_finding exactly once with: "No new findings in this cycle."
|
|
298
|
+
Do not output plain text — every insight must be submitted via write_finding.
|
|
299
|
+
TEXT
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Implements the "Agent teams" coordination pattern (Anthropic blog, Pattern 3).
|
|
6
|
+
#
|
|
7
|
+
# @see https://claude.com/blog/multi-agent-coordination-patterns
|
|
8
|
+
#
|
|
9
|
+
# A coordinator LLM agent decomposes work into tasks and enqueues them
|
|
10
|
+
# dynamically via built-in tools. A fixed pool of worker agents claims tasks
|
|
11
|
+
# from the shared queue, carrying forward their conversation history across
|
|
12
|
+
# assignments to accumulate domain context over time.
|
|
13
|
+
#
|
|
14
|
+
# The coordinator is an {Agent::Base} subclass that has two built-in tools:
|
|
15
|
+
# - +enqueue_task+ — adds a task description to the queue
|
|
16
|
+
# - +finalize+ — signals that all tasks have been enqueued
|
|
17
|
+
#
|
|
18
|
+
# Worker persistence is implemented by passing each worker's accumulated
|
|
19
|
+
# +messages+ array back as a top-level +messages:+ argument on every subsequent
|
|
20
|
+
# +invoke+ call, so the LLM retains context across multiple task assignments.
|
|
21
|
+
#
|
|
22
|
+
# @example Basic usage
|
|
23
|
+
# class MigrationTeam < Phronomy::Agent::TeamCoordinator
|
|
24
|
+
# coordinator_model "claude-3-5-sonnet-20241022"
|
|
25
|
+
# coordinator_instructions <<~INST
|
|
26
|
+
# Analyze the request and enqueue one migration task per service.
|
|
27
|
+
# Call enqueue_task for each service, then call finalize.
|
|
28
|
+
# INST
|
|
29
|
+
#
|
|
30
|
+
# pool size: 3, agent: MigrationAgent
|
|
31
|
+
#
|
|
32
|
+
# aggregate do |assignments|
|
|
33
|
+
# { reports: assignments.map { |a| { task: a[:task][:description], result: a[:result] } } }
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# result = MigrationTeam.new.invoke("Migrate all services to Rails 8")
|
|
38
|
+
class TeamCoordinator
|
|
39
|
+
# Holds per-worker context between task invocations.
|
|
40
|
+
# Worker persistence is implemented by carrying +messages+ forward on each
|
|
41
|
+
# successive +agent#invoke+ call as the top-level +messages:+ argument..
|
|
42
|
+
WorkerState = Struct.new(
|
|
43
|
+
:index, # Integer — 0-based worker index
|
|
44
|
+
:agent, # Agent::Base instance
|
|
45
|
+
:messages, # Array — accumulated conversation history
|
|
46
|
+
:status, # Symbol — :idle | :available | :done
|
|
47
|
+
keyword_init: true
|
|
48
|
+
) do
|
|
49
|
+
# Returns true when this worker is ready to accept the next task.
|
|
50
|
+
def available? = [:idle, :available].include?(status)
|
|
51
|
+
end
|
|
52
|
+
private_constant :WorkerState
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
# Sets the LLM model for the coordinator agent.
|
|
56
|
+
# Falls back to +Phronomy.configuration.default_model+ when not set.
|
|
57
|
+
#
|
|
58
|
+
# @param value [String, nil]
|
|
59
|
+
def coordinator_model(value = nil)
|
|
60
|
+
value ? @coordinator_model = value : @coordinator_model
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Sets the system instructions for the coordinator agent.
|
|
64
|
+
# The prompt should direct the LLM to call +enqueue_task+ for each task
|
|
65
|
+
# and then call +finalize+ when all tasks are enqueued.
|
|
66
|
+
#
|
|
67
|
+
# @param value [String, nil]
|
|
68
|
+
def coordinator_instructions(value = nil)
|
|
69
|
+
value ? @coordinator_instructions = value : @coordinator_instructions
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Sets the LLM provider for the coordinator agent.
|
|
73
|
+
# Required when using a custom +BASE_URL+ (e.g. LM Studio, Ollama, vLLM)
|
|
74
|
+
# so that RubyLLM does not attempt to resolve an unknown model name.
|
|
75
|
+
# Pass the same value as +LLMConfig::PROVIDER+ in your examples.
|
|
76
|
+
#
|
|
77
|
+
# @param value [Symbol, nil]
|
|
78
|
+
def coordinator_provider(value = nil)
|
|
79
|
+
value ? @coordinator_provider = value : @coordinator_provider
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Configures the worker pool.
|
|
83
|
+
#
|
|
84
|
+
# @param size [Integer] number of persistent worker instances
|
|
85
|
+
# @param agent [Class] Agent::Base subclass used for all workers
|
|
86
|
+
# @param on_error [Symbol] +:raise+ (default) propagates worker exceptions;
|
|
87
|
+
# +:skip+ records the failure and continues with remaining tasks
|
|
88
|
+
def pool(size:, agent:, on_error: :raise)
|
|
89
|
+
@pool_size = Integer(size)
|
|
90
|
+
@worker_agent = agent
|
|
91
|
+
@on_error = on_error
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Customises the worker selection algorithm.
|
|
95
|
+
# The block receives an Array of available WorkerState objects and must
|
|
96
|
+
# return the one to assign the next task to.
|
|
97
|
+
# Default: worker with the fewest accumulated messages (round-robin-like).
|
|
98
|
+
#
|
|
99
|
+
# @yield [Array<WorkerState>] available workers
|
|
100
|
+
# @yieldreturn [WorkerState] the chosen worker
|
|
101
|
+
def schedule(&block)
|
|
102
|
+
@scheduler = block
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Defines how task assignments are merged into the final return value.
|
|
106
|
+
# The block receives an Array of assignment Hashes:
|
|
107
|
+
# { task: Hash, result: String|nil, worker: Integer, error: Exception|nil }
|
|
108
|
+
# When omitted, the raw assignments array is returned.
|
|
109
|
+
#
|
|
110
|
+
# @yield [Array<Hash>] all completed (and skipped) task assignments
|
|
111
|
+
def aggregate(&block)
|
|
112
|
+
@aggregator = block
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @!visibility private
|
|
116
|
+
def _coordinator_model = @coordinator_model
|
|
117
|
+
# @!visibility private
|
|
118
|
+
def _coordinator_instructions = @coordinator_instructions
|
|
119
|
+
# @!visibility private
|
|
120
|
+
def _coordinator_provider = @coordinator_provider
|
|
121
|
+
# @!visibility private
|
|
122
|
+
def _pool_size = @pool_size || 1
|
|
123
|
+
# @!visibility private
|
|
124
|
+
def _worker_agent = @worker_agent
|
|
125
|
+
# @!visibility private
|
|
126
|
+
def _on_error = @on_error || :raise
|
|
127
|
+
# @!visibility private
|
|
128
|
+
def _scheduler = @scheduler
|
|
129
|
+
# @!visibility private
|
|
130
|
+
def _aggregator = @aggregator
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Runs the full team coordination: coordinator generates tasks, workers
|
|
134
|
+
# process them sequentially, and the aggregate block merges the results.
|
|
135
|
+
#
|
|
136
|
+
# @param team_input [String, Hash] the high-level objective given to the coordinator
|
|
137
|
+
# @param config [Hash] reserved for future use
|
|
138
|
+
# @return [Object] the return value of the aggregate block, or the raw assignments Array
|
|
139
|
+
# @raise [ArgumentError] when +pool :agent+ has not been configured
|
|
140
|
+
def invoke(team_input, config: {})
|
|
141
|
+
raise ArgumentError, "pool :agent must be configured before invoking" unless self.class._worker_agent
|
|
142
|
+
|
|
143
|
+
task_queue = []
|
|
144
|
+
run_coordinator(team_input, task_queue)
|
|
145
|
+
assignments = run_workers(task_queue)
|
|
146
|
+
finalize_result(assignments)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Streaming version of +invoke+. Yields a Hash event for each completed or
|
|
150
|
+
# failed task assignment.
|
|
151
|
+
#
|
|
152
|
+
# Yielded Hash keys:
|
|
153
|
+
# :type — +:task_completed+ or +:task_failed+
|
|
154
|
+
# :worker — worker index (Integer)
|
|
155
|
+
# :task — the task Hash from the queue ({ id:, description:, metadata:, enqueued_at: })
|
|
156
|
+
# :result — output string, or +nil+ on failure
|
|
157
|
+
# :error — Exception, or +nil+ on success
|
|
158
|
+
#
|
|
159
|
+
# @param team_input [String, Hash]
|
|
160
|
+
# @param config [Hash]
|
|
161
|
+
# @yield [Hash] one event per completed/failed task
|
|
162
|
+
# @return [Object] same as +invoke+
|
|
163
|
+
# @raise [ArgumentError] when +pool :agent+ has not been configured
|
|
164
|
+
def stream(team_input, config: {}, &block)
|
|
165
|
+
return invoke(team_input, config: config) unless block
|
|
166
|
+
|
|
167
|
+
raise ArgumentError, "pool :agent must be configured before invoking" unless self.class._worker_agent
|
|
168
|
+
|
|
169
|
+
task_queue = []
|
|
170
|
+
run_coordinator(team_input, task_queue)
|
|
171
|
+
assignments = run_workers(task_queue, &block)
|
|
172
|
+
finalize_result(assignments)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# Phase 1: Run the coordinator LLM agent to populate task_queue.
|
|
178
|
+
def run_coordinator(team_input, task_queue)
|
|
179
|
+
coordinator = build_coordinator_agent(task_queue)
|
|
180
|
+
input = team_input.is_a?(String) ? team_input : team_input.to_s
|
|
181
|
+
coordinator.invoke(input)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Phase 2: Process tasks from the queue using the worker pool.
|
|
185
|
+
# Workers accumulate message history across assignments.
|
|
186
|
+
def run_workers(task_queue, &event_block)
|
|
187
|
+
pool_size = self.class._pool_size
|
|
188
|
+
agent_class = self.class._worker_agent
|
|
189
|
+
on_error = self.class._on_error
|
|
190
|
+
scheduler = self.class._scheduler
|
|
191
|
+
|
|
192
|
+
workers = Array.new(pool_size) do |i|
|
|
193
|
+
WorkerState.new(index: i, agent: agent_class.new, messages: [], status: :idle)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
assignments = []
|
|
197
|
+
|
|
198
|
+
until task_queue.empty?
|
|
199
|
+
task = task_queue.shift
|
|
200
|
+
available = workers.select(&:available?)
|
|
201
|
+
worker = scheduler ? scheduler.call(available) : default_scheduler(available)
|
|
202
|
+
|
|
203
|
+
begin
|
|
204
|
+
result = worker.agent.invoke(task[:description], messages: worker.messages)
|
|
205
|
+
worker.messages = result[:messages]
|
|
206
|
+
worker.status = :available
|
|
207
|
+
entry = {task: task, result: result[:output], worker: worker.index, error: nil}
|
|
208
|
+
assignments << entry
|
|
209
|
+
event_block&.call(entry.merge(type: :task_completed))
|
|
210
|
+
rescue => e
|
|
211
|
+
worker.status = :available
|
|
212
|
+
raise unless on_error == :skip
|
|
213
|
+
|
|
214
|
+
entry = {task: task, result: nil, worker: worker.index, error: e}
|
|
215
|
+
assignments << entry
|
|
216
|
+
event_block&.call(entry.merge(type: :task_failed))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
workers.each { |w| w.status = :done }
|
|
221
|
+
assignments
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Phase 3: Apply the aggregate block (or return raw assignments).
|
|
225
|
+
def finalize_result(assignments)
|
|
226
|
+
aggregator = self.class._aggregator
|
|
227
|
+
aggregator ? aggregator.call(assignments) : assignments
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Default scheduler: assign to the worker with the fewest accumulated
|
|
231
|
+
# messages (promotes round-robin-like distribution across the pool).
|
|
232
|
+
def default_scheduler(available_workers)
|
|
233
|
+
available_workers.min_by { |w| w.messages.size }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Build an anonymous coordinator Agent::Base with the two built-in tools.
|
|
237
|
+
def build_coordinator_agent(task_queue)
|
|
238
|
+
coordinator_model_val = self.class._coordinator_model
|
|
239
|
+
coordinator_instructions_val = self.class._coordinator_instructions
|
|
240
|
+
coordinator_provider_val = self.class._coordinator_provider
|
|
241
|
+
enqueue_tool = build_enqueue_tool(task_queue)
|
|
242
|
+
finalize_tool = build_finalize_tool(task_queue)
|
|
243
|
+
|
|
244
|
+
coordinator_class = Class.new(Phronomy::Agent::Base) do
|
|
245
|
+
model coordinator_model_val
|
|
246
|
+
provider coordinator_provider_val if coordinator_provider_val
|
|
247
|
+
instructions coordinator_instructions_val
|
|
248
|
+
tools enqueue_tool, finalize_tool
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
coordinator_class.new
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Builds the +enqueue_task+ tool. Each call appends a task Hash to task_queue.
|
|
255
|
+
def build_enqueue_tool(task_queue)
|
|
256
|
+
Class.new(Phronomy::Tool::Base) do
|
|
257
|
+
tool_name "enqueue_task"
|
|
258
|
+
description "Add a task to the worker queue."
|
|
259
|
+
param :description, type: :string, desc: "What the worker agent should do"
|
|
260
|
+
param :metadata, type: :string, desc: "Optional metadata", required: false
|
|
261
|
+
|
|
262
|
+
define_method(:execute) do |description:, metadata: nil|
|
|
263
|
+
task = {id: task_queue.size + 1, description: description, metadata: metadata, enqueued_at: Time.now}
|
|
264
|
+
task_queue << task
|
|
265
|
+
"Task ##{task[:id]} enqueued: #{description}"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Builds the +finalize+ tool. Signals to the coordinator LLM that all tasks
|
|
271
|
+
# have been enqueued; returns a confirmation string.
|
|
272
|
+
def build_finalize_tool(task_queue)
|
|
273
|
+
Class.new(Phronomy::Tool::Base) do
|
|
274
|
+
tool_name "finalize"
|
|
275
|
+
description "Signal that task generation is complete. Call this after all tasks have been enqueued."
|
|
276
|
+
param :summary, type: :string, desc: "Brief summary of what was enqueued", required: false
|
|
277
|
+
|
|
278
|
+
define_method(:execute) do |summary: ""|
|
|
279
|
+
"Finalized. #{task_queue.size} task(s) enqueued. #{summary}".strip
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|