phronomy 0.2.2 → 0.4.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -30
  3. data/README.md +106 -122
  4. data/lib/phronomy/agent/base.rb +135 -57
  5. data/lib/phronomy/agent/checkpoint.rb +53 -0
  6. data/lib/phronomy/agent/orchestrator.rb +119 -0
  7. data/lib/phronomy/agent/react_agent.rb +18 -28
  8. data/lib/phronomy/agent/shared_state.rb +303 -0
  9. data/lib/phronomy/agent/suspend_signal.rb +35 -0
  10. data/lib/phronomy/agent/team_coordinator.rb +285 -0
  11. data/lib/phronomy/agent.rb +2 -1
  12. data/lib/phronomy/configuration.rb +0 -24
  13. data/lib/phronomy/generator_verifier.rb +250 -0
  14. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
  15. data/lib/phronomy/railtie.rb +0 -6
  16. data/lib/phronomy/ruby_llm_patches.rb +20 -0
  17. data/lib/phronomy/tool/mcp_tool.rb +23 -26
  18. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
  19. data/lib/phronomy/vector_store/redis_search.rb +4 -4
  20. data/lib/phronomy/version.rb +1 -1
  21. data/lib/phronomy/workflow.rb +4 -7
  22. data/lib/phronomy/workflow_runner.rb +42 -30
  23. data/lib/phronomy.rb +18 -0
  24. data/scripts/check_readme_ruby.rb +38 -0
  25. metadata +12 -38
  26. data/docs/trustworthy_ai_enhancements.md +0 -332
  27. data/lib/phronomy/active_record/acts_as.rb +0 -48
  28. data/lib/phronomy/active_record/checkpoint.rb +0 -20
  29. data/lib/phronomy/active_record/extensions.rb +0 -14
  30. data/lib/phronomy/active_record/message.rb +0 -20
  31. data/lib/phronomy/actor.rb +0 -68
  32. data/lib/phronomy/memory/compression/base.rb +0 -37
  33. data/lib/phronomy/memory/compression/summary.rb +0 -107
  34. data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
  35. data/lib/phronomy/memory/compression.rb +0 -11
  36. data/lib/phronomy/memory/conversation_manager.rb +0 -213
  37. data/lib/phronomy/memory/retrieval/base.rb +0 -22
  38. data/lib/phronomy/memory/retrieval/composite.rb +0 -76
  39. data/lib/phronomy/memory/retrieval/recent.rb +0 -35
  40. data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
  41. data/lib/phronomy/memory/retrieval.rb +0 -12
  42. data/lib/phronomy/memory/storage/active_record.rb +0 -248
  43. data/lib/phronomy/memory/storage/base.rb +0 -155
  44. data/lib/phronomy/memory/storage/in_memory.rb +0 -152
  45. data/lib/phronomy/memory/storage.rb +0 -11
  46. data/lib/phronomy/memory.rb +0 -21
  47. data/lib/phronomy/rails/agent_job.rb +0 -75
  48. data/lib/phronomy/state_store/active_record.rb +0 -76
  49. data/lib/phronomy/state_store/base.rb +0 -112
  50. data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
  51. data/lib/phronomy/state_store/encryptor/base.rb +0 -34
  52. data/lib/phronomy/state_store/encryptor.rb +0 -16
  53. data/lib/phronomy/state_store/file.rb +0 -85
  54. data/lib/phronomy/state_store/in_memory.rb +0 -53
  55. data/lib/phronomy/state_store/redis.rb +0 -70
  56. data/lib/phronomy/state_store.rb +0 -9
  57. data/lib/phronomy/thread_actor_registry.rb +0 -85
  58. data/lib/phronomy/trust_pipeline.rb +0 -264
@@ -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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Raised internally inside the on_tool_call hook when an approval-required
6
+ # tool is encountered and no synchronous on_approval_required handler has
7
+ # been registered. Caught by Agent::Base#invoke_once to produce a
8
+ # suspended result hash containing a Checkpoint.
9
+ #
10
+ # This class is intentionally NOT part of the public API. Callers should
11
+ # inspect the +:suspended+ key in the result hash returned by #invoke.
12
+ #
13
+ # @api private
14
+ class SuspendSignal < StandardError
15
+ # @return [String] the name of the tool that triggered the suspension
16
+ attr_reader :tool_name
17
+
18
+ # @return [Hash] the arguments the LLM passed to the tool
19
+ attr_reader :args
20
+
21
+ # @return [String] the tool_call_id from the LLM response
22
+ attr_reader :tool_call_id
23
+
24
+ # @param tool_name [String]
25
+ # @param args [Hash]
26
+ # @param tool_call_id [String]
27
+ def initialize(tool_name:, args:, tool_call_id:)
28
+ super("Agent suspended waiting for approval of tool: #{tool_name}")
29
+ @tool_name = tool_name
30
+ @args = args
31
+ @tool_call_id = tool_call_id
32
+ end
33
+ end
34
+ end
35
+ 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 via +config[:messages]+ on every subsequent +invoke+
20
+ # 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 via +config[:messages]+.
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], config: {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
@@ -7,7 +7,8 @@ module Phronomy
7
7
  # type values:
8
8
  # :token — a content delta from the LLM (payload: { content: String })
9
9
  # :tool_call — the LLM requested a tool call (payload: { tool_call: Object })
10
- # :tool_result — a tool finished executing (payload: { tool_result: Object })
10
+ # :tool_result — a tool finished executing (payload: { tool_call_id: String, tool_name: String,
11
+ # tool_result: Object })
11
12
  # :done — the agent finished (payload: { output: String, messages: Array,
12
13
  # usage: TokenUsage })
13
14
  # :error — an unrecoverable error occurred (payload: { error: Exception })
@@ -16,20 +16,6 @@ module Phronomy
16
16
  # Default embedding model name
17
17
  attr_accessor :default_embedding_model
18
18
 
19
- # Default StateStore instance (nil = no persistence)
20
- attr_accessor :default_state_store
21
-
22
- # Default Memory instance
23
- attr_accessor :default_memory
24
-
25
- # When true, all memory backends write asynchronously via ActiveJob by default.
26
- # Individual instances can still override with their own async: option.
27
- # Requires ActiveJob to be available.
28
- attr_accessor :memory_async
29
-
30
- # ActiveJob queue name used for async memory writes (default: :default)
31
- attr_accessor :memory_job_queue
32
-
33
19
  # Tracer instance
34
20
  attr_accessor :tracer
35
21
 
@@ -47,20 +33,10 @@ module Phronomy
47
33
  # the tracing backend (OTel, Langfuse, etc.).
48
34
  attr_accessor :trace_pii
49
35
 
50
- # Maximum number of Actors that {ThreadActorRegistry} may hold simultaneously.
51
- # When the registry is full, the least-recently-used Actor is stopped and
52
- # evicted before a new one is created.
53
- # Defaults to +nil+ (no limit). Set to a positive integer for long-running
54
- # server processes that handle many distinct conversation threads.
55
- attr_accessor :max_actors
56
-
57
36
  def initialize
58
37
  @recursion_limit = 25
59
38
  @tracer = Phronomy::Tracing::NullTracer.new
60
- @memory_async = false
61
- @memory_job_queue = :default
62
39
  @trace_pii = true
63
- @max_actors = nil
64
40
  end
65
41
  end
66
42
  end