phronomy 0.6.0 → 0.7.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +338 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +242 -27
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +194 -12
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +15 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +21 -4
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +26 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/embeddings/base.rb +5 -2
  53. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  54. data/lib/phronomy/eval/comparison.rb +2 -0
  55. data/lib/phronomy/eval/dataset.rb +4 -0
  56. data/lib/phronomy/eval/metrics.rb +6 -0
  57. data/lib/phronomy/eval/runner.rb +2 -0
  58. data/lib/phronomy/eval/scorer/base.rb +1 -0
  59. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  60. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  61. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  62. data/lib/phronomy/event_loop.rb +114 -7
  63. data/lib/phronomy/fsm_session.rb +8 -1
  64. data/lib/phronomy/generator_verifier.rb +2 -0
  65. data/lib/phronomy/guardrail/base.rb +3 -0
  66. data/lib/phronomy/knowledge_source/base.rb +6 -2
  67. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  68. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  69. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  70. data/lib/phronomy/loader/base.rb +1 -0
  71. data/lib/phronomy/loader/csv_loader.rb +2 -0
  72. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  73. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  74. data/lib/phronomy/output_parser/base.rb +1 -0
  75. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  76. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  77. data/lib/phronomy/prompt_template.rb +5 -0
  78. data/lib/phronomy/runnable.rb +20 -3
  79. data/lib/phronomy/splitter/base.rb +2 -0
  80. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  81. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  82. data/lib/phronomy/state_store/base.rb +48 -0
  83. data/lib/phronomy/state_store/in_memory.rb +62 -0
  84. data/lib/phronomy/tool/agent_tool.rb +1 -0
  85. data/lib/phronomy/tool/base.rb +189 -27
  86. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  87. data/lib/phronomy/tracing/base.rb +3 -0
  88. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  89. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  90. data/lib/phronomy/vector_store/base.rb +33 -7
  91. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  92. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  93. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  94. data/lib/phronomy/version.rb +1 -1
  95. data/lib/phronomy/workflow.rb +96 -7
  96. data/lib/phronomy/workflow_context.rb +54 -4
  97. data/lib/phronomy/workflow_runner.rb +35 -7
  98. data/lib/phronomy.rb +70 -1
  99. data/scripts/api_snapshot.rb +91 -0
  100. data/scripts/check_api_annotations.rb +68 -0
  101. data/scripts/check_private_enforcement.rb +93 -0
  102. data/scripts/check_readme_runnable.rb +98 -0
  103. data/scripts/run_mutation.sh +46 -0
  104. metadata +45 -2
@@ -8,6 +8,7 @@ module Phronomy
8
8
  # Included in {Phronomy::Agent::Base}. When a tool decorated with
9
9
  # +requires_approval true+ is called and no synchronous approval handler
10
10
  # has been registered, the invocation is suspended and a
11
+ # @api private
11
12
  # {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
12
13
  module Suspendable
13
14
  # Registers a callback that is invoked before executing any tool that has
@@ -25,6 +26,7 @@ module Phronomy
25
26
  # agent = MyAgent.new
26
27
  # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
27
28
  # @return [self]
29
+ # @api private
28
30
  def on_approval_required(&block)
29
31
  @approval_handler = block
30
32
  self
@@ -43,6 +45,7 @@ module Phronomy
43
45
  # @param config [Hash] same runtime options as #invoke
44
46
  # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
45
47
  # @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
48
+ # @api private
46
49
  def resume(checkpoint, approved:, config: {})
47
50
  # Build a fresh chat with all tools registered.
48
51
  chat = build_chat
@@ -95,6 +98,7 @@ module Phronomy
95
98
  # - none of the agent's tools have requires_approval set.
96
99
  #
97
100
  # @param chat [RubyLLM::Chat]
101
+ # @api private
98
102
  def _register_suspension_hook!(chat)
99
103
  return if @approval_handler
100
104
  return if self.class.tools.none? { |tc| tc.requires_approval }
@@ -57,6 +57,7 @@ module Phronomy
57
57
  # {Agent::Base#run_as_child} creates an +AgentFSM+ with +parent_id+ set to
58
58
  # +ctx.thread_id+, registers it with the EventLoop, and returns immediately.
59
59
  # The parent {FSMSession} waits for the +:child_completed+ event.
60
+ # @api private
60
61
  class FSM
61
62
  # @return [String] unique identifier used as the EventLoop target_id
62
63
  attr_reader :id
@@ -87,6 +88,7 @@ module Phronomy
87
88
  # entry :run_agent, ->(ctx) {
88
89
  # MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
89
90
  # }
91
+ # @api private
90
92
  def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil, result_writer: nil)
91
93
  @agent = agent
92
94
  @input = input
@@ -131,6 +133,9 @@ module Phronomy
131
133
  Thread.new do
132
134
  # Enable parallel tool dispatch inside this IO thread.
133
135
  Thread.current[:phronomy_agent_parallel_tools] = true
136
+ # Forward the concurrency cap to ParallelToolChat.
137
+ Thread.current[:phronomy_max_parallel_tools] =
138
+ agent.class.respond_to?(:max_parallel_tools) ? agent.class.max_parallel_tools : 10
134
139
 
135
140
  begin
136
141
  result = agent.send(:_invoke_impl,
@@ -154,9 +159,19 @@ module Phronomy
154
159
  Phronomy::Event.new(type: :finished, target_id: fsm_id, payload: result)
155
160
  )
156
161
  rescue => e
162
+ if parent_id
163
+ Phronomy::EventLoop.instance.post(
164
+ Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
165
+ )
166
+ end
167
+
157
168
  Phronomy::EventLoop.instance.post(
158
169
  Phronomy::Event.new(type: :error, target_id: fsm_id, payload: e)
159
170
  )
171
+ ensure
172
+ # Clear the thread-local context cache for this agent so the IO
173
+ # thread's cache does not grow unboundedly across invocations.
174
+ Thread.current[:phronomy_context_version_caches]&.delete(agent.object_id)
160
175
  end
161
176
  end
162
177
  end
@@ -22,6 +22,7 @@ module Phronomy
22
22
 
23
23
  # @param target_agent [Phronomy::Agent::Base] the agent to hand off to
24
24
  # @param description [String, nil] overrides the auto-generated tool description
25
+ # @api public
25
26
  def initialize(target_agent:, description: nil)
26
27
  @target_agent = target_agent
27
28
  klass_name = target_agent.class.name&.split("::")&.last || "Agent"
@@ -33,6 +34,7 @@ module Phronomy
33
34
 
34
35
  # Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
35
36
  # @return [Class<Phronomy::Tool::Base>]
37
+ # @api public
36
38
  def to_tool_class
37
39
  sentinel_value = sentinel
38
40
  tn = tool_name
@@ -46,6 +48,7 @@ module Phronomy
46
48
 
47
49
  # The sentinel string embedded in the tool result.
48
50
  # @return [String]
51
+ # @api public
49
52
  def sentinel
50
53
  "#{SENTINEL_PREFIX}:#{target_agent.class.name}:#{@uuid}"
51
54
  end
@@ -55,6 +55,7 @@ module Phronomy
55
55
  # @param on_error [Symbol] +:raise+ (default) re-raises any exception
56
56
  # from the subagent; +:skip+ returns +nil+ so the LLM can decide how to
57
57
  # proceed
58
+ # @api public
58
59
  def self.subagent(name, agent_class, on_error: :raise)
59
60
  tool_class = Class.new(Phronomy::Tool::Base) do
60
61
  tool_name "dispatch_to_#{name}"
@@ -62,7 +63,14 @@ module Phronomy
62
63
  param :input, type: :string, desc: "The task or question for the subagent"
63
64
 
64
65
  define_method(:execute) do |input:|
65
- result = agent_class.new.invoke(input)
66
+ # Inherit the calling orchestrator's thread_id and config when
67
+ # available so that sub-agent spans and memory stay connected.
68
+ ctx = Thread.current[:phronomy_orchestrator_context] || {}
69
+ result = agent_class.new.invoke(
70
+ input,
71
+ thread_id: ctx[:thread_id],
72
+ config: ctx[:config] || {}
73
+ )
66
74
  result[:output]
67
75
  rescue
68
76
  raise if on_error == :raise
@@ -80,6 +88,7 @@ module Phronomy
80
88
  # Returns the subagent registry for this specific class (not inherited).
81
89
  #
82
90
  # @return [Hash{Symbol => Hash}]
91
+ # @api public
83
92
  def self.registered_subagents
84
93
  @registered_subagents ||= {}
85
94
  end
@@ -100,13 +109,28 @@ module Phronomy
100
109
  # @option task [Class] :agent agent class to invoke (required)
101
110
  # @option task [String] :input input string for the agent (required)
102
111
  # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
103
- # @param max_concurrency [Integer, nil] maximum number of concurrent threads;
112
+ # @option task [String] :thread_id forwarded to +agent#invoke+ (default: nil)
113
+ # @param max_concurrency [Integer, nil] maximum number of concurrent threads;
104
114
  # nil means no limit (all tasks run simultaneously)
105
- # @param on_error [Symbol] +:raise+ or +:skip+
115
+ # @param on_error [Symbol] +:raise+ or +:skip+
116
+ # @param timeout [Numeric, nil] maximum seconds to wait for all workers;
117
+ # nil means wait indefinitely. When the deadline is exceeded,
118
+ # {Phronomy::TimeoutError} is raised and all surviving worker threads are killed.
119
+ # @param cancellation_token [Phronomy::CancellationToken, nil] when provided, the
120
+ # token is merged into each task's config (unless the task already sets one) so
121
+ # that every worker agent checks it before making LLM calls.
122
+ # @param force_kill [Boolean] when +true+, surviving worker threads are killed with
123
+ # +Thread#kill+ after the grace period if they do not stop cooperatively. When
124
+ # +false+ (default), workers are asked to stop cooperatively but are never killed;
125
+ # the caller receives {Phronomy::TimeoutError} immediately and abandoned workers
126
+ # discard their results when they eventually finish. +false+ is safer for
127
+ # production because +Thread#kill+ can interrupt +ensure+ blocks.
106
128
  # @return [Array<Hash, nil>] agent results in the same order as +tasks+
107
129
  # @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
108
130
  # @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
109
- def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise)
131
+ # @raise [Phronomy::TimeoutError] if +timeout+ is exceeded
132
+ # @api public
133
+ def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, force_kill: false)
110
134
  unless [:raise, :skip].include?(on_error)
111
135
  raise ArgumentError, "unknown on_error: #{on_error.inspect}"
112
136
  end
@@ -114,7 +138,7 @@ module Phronomy
114
138
  raise ArgumentError, "max_concurrency must be a positive Integer"
115
139
  end
116
140
 
117
- bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error)
141
+ bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error, timeout: timeout, cancellation_token: cancellation_token, force_kill: force_kill)
118
142
  end
119
143
 
120
144
  # Runs the same agent against multiple inputs in parallel (fan-out pattern).
@@ -125,19 +149,52 @@ module Phronomy
125
149
  # @param agent [Class] agent class to invoke for every input
126
150
  # @param inputs [Array<String>] list of input strings
127
151
  # @param config [Hash] forwarded to every +agent#invoke+ call
152
+ # @param thread_id [String, nil] forwarded to every +agent#invoke+ call
128
153
  # @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
129
154
  # @param on_error [Symbol] forwarded to {#dispatch_parallel}
130
155
  # @return [Array<Hash, nil>] results in the same order as +inputs+
131
- def fan_out(agent:, inputs:, config: {}, max_concurrency: nil, on_error: :raise)
156
+ # @api public
157
+ def fan_out(agent:, inputs:, config: {}, thread_id: nil, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, force_kill: false)
132
158
  dispatch_parallel(
133
- *inputs.map { |input| {agent: agent, input: input, config: config} },
159
+ *inputs.map { |input| {agent: agent, input: input, config: config, thread_id: thread_id} },
134
160
  max_concurrency: max_concurrency,
135
- on_error: on_error
161
+ on_error: on_error,
162
+ timeout: timeout,
163
+ cancellation_token: cancellation_token,
164
+ force_kill: force_kill
165
+ )
166
+ end
167
+
168
+ # Programmatically dispatches a single sub-agent from inside an orchestrator
169
+ # instance, inheriting the parent's +thread_id+ and +config+ by default.
170
+ #
171
+ # @param agent_class [Class] subclass of {Phronomy::Agent::Base}
172
+ # @param input [String] task or question for the sub-agent
173
+ # @param config [Hash, nil] override config (falls back to parent's)
174
+ # @param thread_id [String, nil] override thread_id (falls back to parent's)
175
+ # @return [Hash] the sub-agent's result hash (+:output+, +:messages+)
176
+ # @api public
177
+ def subagent(agent_class, input, config: nil, thread_id: nil)
178
+ ctx = Thread.current[:phronomy_orchestrator_context] || {}
179
+ agent_class.new.invoke(
180
+ input,
181
+ config: config || ctx[:config] || {},
182
+ thread_id: thread_id || ctx[:thread_id]
136
183
  )
137
184
  end
138
185
 
139
186
  private
140
187
 
188
+ # Override invoke_once to expose the current thread_id and config via a
189
+ # thread-local so that DSL-registered subagent tools can inherit them.
190
+ def invoke_once(input, messages: [], thread_id: nil, config: {})
191
+ prev = Thread.current[:phronomy_orchestrator_context]
192
+ Thread.current[:phronomy_orchestrator_context] = {thread_id: thread_id, config: config}
193
+ super
194
+ ensure
195
+ Thread.current[:phronomy_orchestrator_context] = prev
196
+ end
197
+
141
198
  # Worker-pool implementation shared by {#dispatch_parallel} and {#fan_out}.
142
199
  #
143
200
  # Uses a +Queue+ as a work-stealing mechanism: each worker thread pops a
@@ -150,12 +207,27 @@ module Phronomy
150
207
  # A +Mutex+ guards concurrent writes to +errors+ even though Array element
151
208
  # assignment at different indices is safe in MRI; this keeps the code
152
209
  # correct across alternative Ruby runtimes.
153
- def bounded_map(tasks, max_concurrency:, on_error:)
210
+ #
211
+ # When +timeout+ is given, workers are first asked to stop cooperatively
212
+ # via a cancellation flag (so they do not pick up new tasks) and then given
213
+ # +KILL_GRACE_SECONDS+ to finish any in-flight +ensure+ blocks. Only
214
+ # workers that are still alive after the grace period are force-killed, and
215
+ # a warning is logged in that case. Use a +CancellationToken+ (see #216)
216
+ # for full cooperative cancellation of long-running tasks.
217
+ #
218
+ # Deadline tracking uses +Process.clock_gettime(Process::CLOCK_MONOTONIC)+
219
+ # to avoid sensitivity to NTP adjustments and system-clock changes.
220
+ KILL_GRACE_SECONDS = 0.5
221
+ private_constant :KILL_GRACE_SECONDS
222
+
223
+ def bounded_map(tasks, max_concurrency:, on_error:, timeout: nil, cancellation_token: nil, force_kill: false)
154
224
  return [] if tasks.empty?
155
225
 
156
226
  results = Array.new(tasks.length)
157
227
  errors = Array.new(tasks.length)
158
228
  errors_mutex = Mutex.new
229
+ # Mutex-backed cooperative stop token; workers check before each task pick-up.
230
+ internal_stop_token = Phronomy::CancellationToken.new
159
231
 
160
232
  queue = Queue.new
161
233
  tasks.each_with_index { |task, i| queue << [i, task] }
@@ -165,16 +237,26 @@ module Phronomy
165
237
  workers = worker_count.times.map do
166
238
  Thread.new do
167
239
  loop do
240
+ break if internal_stop_token.cancelled?
241
+
168
242
  i, task = begin
169
243
  queue.pop(true)
170
244
  rescue ThreadError
171
245
  break # queue is empty; this worker is done
172
246
  end
173
247
 
248
+ # Merge the shared cancellation token into the task's config unless
249
+ # the task already supplies its own token.
250
+ task_config = task.fetch(:config, {})
251
+ if cancellation_token && !task_config[:cancellation_token]
252
+ task_config = task_config.merge(cancellation_token: cancellation_token)
253
+ end
254
+
174
255
  begin
175
256
  results[i] = task[:agent].new.invoke(
176
257
  task[:input],
177
- config: task.fetch(:config, {})
258
+ config: task_config,
259
+ thread_id: task[:thread_id]
178
260
  )
179
261
  rescue => e
180
262
  case on_error
@@ -188,7 +270,37 @@ module Phronomy
188
270
  end
189
271
  end
190
272
 
191
- workers.each(&:join)
273
+ workers.each(&:join) if timeout.nil?
274
+
275
+ if timeout
276
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
277
+ workers.each do |w|
278
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
279
+ w.join([remaining, 0].max)
280
+ end
281
+
282
+ alive = workers.select(&:alive?)
283
+ unless alive.empty?
284
+ # Signal workers cooperatively to stop picking up new tasks.
285
+ internal_stop_token.cancel!
286
+ if force_kill
287
+ # Give in-flight ensure blocks a short grace period before kill.
288
+ alive.each { |w| w.join(KILL_GRACE_SECONDS) }
289
+ still_alive = alive.select(&:alive?)
290
+ if still_alive.any?
291
+ Phronomy.configuration.logger&.warn(
292
+ "[Phronomy] dispatch_parallel: #{still_alive.length} worker(s) did not stop " \
293
+ "within grace period; force-killing. Use CancellationToken for " \
294
+ "cooperative cancellation of long-running tasks."
295
+ )
296
+ still_alive.each(&:kill)
297
+ end
298
+ end
299
+ raise Phronomy::TimeoutError,
300
+ "dispatch_parallel timed out after #{timeout}s " \
301
+ "(#{alive.length} of #{workers.length} workers still running)"
302
+ end
303
+ end
192
304
 
193
305
  first_error = errors.compact.first
194
306
  raise first_error if first_error
@@ -18,6 +18,7 @@ module Phronomy
18
18
  # {AgentFSM} IO thread (i.e. when the +:phronomy_agent_parallel_tools+
19
19
  # thread-local flag is +true+). It is not used for direct synchronous
20
20
  # +invoke+ calls so that the streaming callback state remains single-threaded.
21
+ # @api private
21
22
  class ParallelToolChat < RubyLLM::Chat
22
23
  private
23
24
 
@@ -34,6 +35,7 @@ module Phronomy
34
35
  #
35
36
  # @param response [RubyLLM::Message] the LLM response containing tool calls
36
37
  # @yield streaming block forwarded to +complete+
38
+ # @api private
37
39
  def handle_tool_calls(response, &block)
38
40
  tool_calls = response.tool_calls.values
39
41
 
@@ -50,14 +52,29 @@ module Phronomy
50
52
  end
51
53
 
52
54
  # Phase 2 — parallel tool execution.
53
- thread_results = tool_calls.map do |tool_call|
54
- Thread.new { {tool_call: tool_call, result: execute_tool(tool_call)} }
55
+ # Honour the per-agent concurrency cap (max_parallel_tools DSL).
56
+ # Tool calls are processed in batches of at most `max` threads;
57
+ # batches run sequentially so the total in-flight thread count never
58
+ # exceeds the limit.
59
+ #
60
+ # Check for cancellation before dispatching each batch so that
61
+ # already-cancelled tokens do not start new LLM/tool-round-trips.
62
+ ct = Thread.current[:phronomy_cancellation_token]
63
+ max = Thread.current[:phronomy_max_parallel_tools] || 10
64
+ thread_results = tool_calls.each_slice(max).flat_map do |batch|
65
+ if ct&.cancelled?
66
+ raise Phronomy::CancellationError, "invocation cancelled before tool execution"
67
+ end
68
+
69
+ threads = batch.map do |tool_call|
70
+ Thread.new { {tool_call: tool_call, result: execute_tool(tool_call)} }
71
+ end
72
+ threads.map(&:value)
55
73
  end
56
- results = thread_results.map(&:value)
57
74
 
58
75
  # Phase 3 — post-execution callbacks and message recording (sequential).
59
76
  halt_result = nil
60
- results.each do |item|
77
+ thread_results.each do |item|
61
78
  result = item[:result]
62
79
  @on[:tool_result]&.call(result)
63
80
  tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
@@ -37,9 +37,10 @@ module Phronomy
37
37
  end
38
38
  end
39
39
 
40
- # Fall back to the last message
41
- # guards against the case where the final message is a tool-call or
42
- output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
40
+ # Select the last assistant-produced content as the output, skipping
41
+ # raw tool result messages (role: :tool) to avoid returning tool JSON
42
+ # or status strings as the agent's answer when iterations are exhausted.
43
+ output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
43
44
 
44
45
  # Run output guardrails before returning to the caller.
45
46
  run_output_guardrails!(output)
@@ -60,6 +61,7 @@ module Phronomy
60
61
  # @param config [Hash]
61
62
  # @yield [Phronomy::Agent::StreamEvent]
62
63
  # @return [Hash] { output:, messages:, usage: }
64
+ # @api public
63
65
  def stream(input, messages: [], thread_id: nil, config: {}, &block)
64
66
  return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
65
67
 
@@ -88,9 +90,9 @@ module Phronomy
88
90
  end
89
91
  end
90
92
 
91
- # Fall back to the last message that carries non-nil content (same as
92
- # the non-streaming path above).
93
- output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
93
+ # Select the last assistant-produced content as the output, skipping
94
+ # raw tool result messages (role: :tool) — same as the non-streaming path.
95
+ output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
94
96
  run_output_guardrails!(output)
95
97
 
96
98
  result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
@@ -30,6 +30,7 @@ module Phronomy
30
30
  # @param routes [Hash{Phronomy::Agent::Base => Array<Phronomy::Agent::Base>}]
31
31
  # declares which target agents each source agent may hand off to;
32
32
  # when omitted no handoffs are configured and the entry agent handles everything
33
+ # @api public
33
34
  def initialize(agents:, routes: {})
34
35
  @agents = Array(agents)
35
36
  raise ArgumentError, "At least one agent is required" if @agents.empty?
@@ -47,6 +48,7 @@ module Phronomy
47
48
  # @param config [Hash] forwarded to each agent's #invoke
48
49
  # @return [Hash] { output:, messages:, usage:, agent: }
49
50
  # @raise [Phronomy::HandoffError] if more than MAX_HANDOFFS handoffs occur
51
+ # @api public
50
52
  def invoke(input, config: {})
51
53
  current = @entry_agent
52
54
  handoffs_taken = 0
@@ -57,6 +57,7 @@ module Phronomy
57
57
 
58
58
  # Returns a shallow copy of all findings in insertion order.
59
59
  # @return [Array<Hash>]
60
+ # @api public
60
61
  def read_all
61
62
  @findings.dup
62
63
  end
@@ -66,6 +67,7 @@ module Phronomy
66
67
  # @param content [String] the finding text
67
68
  # @param cycle [Integer] the current cycle number
68
69
  # @return [nil]
70
+ # @api public
69
71
  def write(agent:, content:, cycle:)
70
72
  @findings << {agent: agent, content: content, cycle: cycle}
71
73
  nil
@@ -73,6 +75,7 @@ module Phronomy
73
75
 
74
76
  # Returns the number of findings recorded so far.
75
77
  # @return [Integer]
78
+ # @api public
76
79
  def size
77
80
  @findings.size
78
81
  end
@@ -85,6 +88,7 @@ module Phronomy
85
88
  # @param klass [Class] an Agent::Base subclass
86
89
  # @param instruction [String, nil] optional per-agent coordination instruction
87
90
  # appended to the team coordination text in this agent's prompt
91
+ # @api public
88
92
  def member(klass, instruction: nil)
89
93
  @members ||= []
90
94
  @members << {klass: klass, instruction: instruction}
@@ -94,6 +98,7 @@ module Phronomy
94
98
  # per-agent instruction. Prefer {.member} for new code.
95
99
  #
96
100
  # @param classes [Array<Class>] Agent::Base subclasses
101
+ # @api public
97
102
  def researchers(*classes)
98
103
  classes.flatten.each { |klass| member(klass) }
99
104
  end
@@ -104,6 +109,7 @@ module Phronomy
104
109
  # workflow. Override this when you need a different protocol or tone.
105
110
  #
106
111
  # @param text [String, nil] the coordination instructions
112
+ # @api public
107
113
  def coordination(text = nil)
108
114
  text ? @coordination = text : @coordination
109
115
  end
@@ -112,6 +118,7 @@ module Phronomy
112
118
  # At least one of +max_cycles+ or +timeout+ must be configured.
113
119
  #
114
120
  # @param value [Integer, nil]
121
+ # @api public
115
122
  def max_cycles(value = nil)
116
123
  value ? @max_cycles = Integer(value) : @max_cycles
117
124
  end
@@ -120,6 +127,7 @@ module Phronomy
120
127
  # At least one of +max_cycles+ or +timeout+ must be configured.
121
128
  #
122
129
  # @param value [Numeric, nil]
130
+ # @api public
123
131
  def timeout(value = nil)
124
132
  value ? @timeout = value.to_f : @timeout
125
133
  end
@@ -128,6 +136,7 @@ module Phronomy
128
136
  # cycle; when it returns +true+ the loop terminates early.
129
137
  #
130
138
  # @yield [KnowledgeStore] receives the store; return +true+ to stop
139
+ # @api public
131
140
  def terminate_when(&block)
132
141
  block ? @terminate_when = block : @terminate_when
133
142
  end
@@ -136,6 +145,7 @@ module Phronomy
136
145
  # When omitted, +store.read_all+ is used as-is.
137
146
  #
138
147
  # @yield [KnowledgeStore] receives the final store; return value becomes +:output+
148
+ # @api public
139
149
  def aggregate(&block)
140
150
  block ? @aggregator = block : @aggregator
141
151
  end
@@ -162,6 +172,7 @@ module Phronomy
162
172
  # @param config [Hash] reserved for future use
163
173
  # @return [Hash] +:output+, +:cycles+, +:terminated_by+
164
174
  # @raise [ArgumentError] when neither +max_cycles+ nor +timeout+ is configured
175
+ # @api public
165
176
  def invoke(input, config: {})
166
177
  validate_termination!
167
178
 
@@ -8,6 +8,7 @@ module Phronomy
8
8
  # suspended result hash containing a Checkpoint.
9
9
  #
10
10
  # This class is intentionally NOT part of the public API. Callers should
11
+ # @api private
11
12
  # inspect the +:suspended+ key in the result hash returned by #invoke.
12
13
  #
13
14
  # @api private
@@ -24,6 +25,7 @@ module Phronomy
24
25
  # @param tool_name [String]
25
26
  # @param args [Hash]
26
27
  # @param tool_call_id [String]
28
+ # @api private
27
29
  def initialize(tool_name:, args:, tool_call_id:)
28
30
  super("Agent suspended waiting for approval of tool: #{tool_name}")
29
31
  @tool_name = tool_name
@@ -7,9 +7,13 @@ module Phronomy
7
7
  # @see https://claude.com/blog/multi-agent-coordination-patterns
8
8
  #
9
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.
10
+ # dynamically via built-in tools. A fixed set of worker agents processes tasks
11
+ # sequentially one task per worker per turn — carrying forward their
12
+ # conversation history across assignments to accumulate domain context over time.
13
+ #
14
+ # Workers are selected in sequence (the worker with the fewest accumulated
15
+ # messages is chosen by default). Task dispatch is synchronous; there is no
16
+ # concurrent or parallel execution.
13
17
  #
14
18
  # The coordinator is an {Agent::Base} subclass that has two built-in tools:
15
19
  # - +enqueue_task+ — adds a task description to the queue
@@ -56,6 +60,7 @@ module Phronomy
56
60
  # Falls back to +Phronomy.configuration.default_model+ when not set.
57
61
  #
58
62
  # @param value [String, nil]
63
+ # @api public
59
64
  def coordinator_model(value = nil)
60
65
  value ? @coordinator_model = value : @coordinator_model
61
66
  end
@@ -65,6 +70,7 @@ module Phronomy
65
70
  # and then call +finalize+ when all tasks are enqueued.
66
71
  #
67
72
  # @param value [String, nil]
73
+ # @api public
68
74
  def coordinator_instructions(value = nil)
69
75
  value ? @coordinator_instructions = value : @coordinator_instructions
70
76
  end
@@ -75,16 +81,18 @@ module Phronomy
75
81
  # Pass the same value as +LLMConfig::PROVIDER+ in your examples.
76
82
  #
77
83
  # @param value [Symbol, nil]
84
+ # @api public
78
85
  def coordinator_provider(value = nil)
79
86
  value ? @coordinator_provider = value : @coordinator_provider
80
87
  end
81
88
 
82
- # Configures the worker pool.
89
+ # Configures the set of workers.
83
90
  #
84
- # @param size [Integer] number of persistent worker instances
91
+ # @param size [Integer] number of persistent worker instances (tasks are assigned sequentially)
85
92
  # @param agent [Class] Agent::Base subclass used for all workers
86
93
  # @param on_error [Symbol] +:raise+ (default) propagates worker exceptions;
87
94
  # +:skip+ records the failure and continues with remaining tasks
95
+ # @api public
88
96
  def pool(size:, agent:, on_error: :raise)
89
97
  @pool_size = Integer(size)
90
98
  @worker_agent = agent
@@ -98,6 +106,7 @@ module Phronomy
98
106
  #
99
107
  # @yield [Array<WorkerState>] available workers
100
108
  # @yieldreturn [WorkerState] the chosen worker
109
+ # @api public
101
110
  def schedule(&block)
102
111
  @scheduler = block
103
112
  end
@@ -108,6 +117,7 @@ module Phronomy
108
117
  # When omitted, the raw assignments array is returned.
109
118
  #
110
119
  # @yield [Array<Hash>] all completed (and skipped) task assignments
120
+ # @api public
111
121
  def aggregate(&block)
112
122
  @aggregator = block
113
123
  end
@@ -137,6 +147,7 @@ module Phronomy
137
147
  # @param config [Hash] reserved for future use
138
148
  # @return [Object] the return value of the aggregate block, or the raw assignments Array
139
149
  # @raise [ArgumentError] when +pool :agent+ has not been configured
150
+ # @api public
140
151
  def invoke(team_input, config: {})
141
152
  raise ArgumentError, "pool :agent must be configured before invoking" unless self.class._worker_agent
142
153
 
@@ -161,6 +172,7 @@ module Phronomy
161
172
  # @yield [Hash] one event per completed/failed task
162
173
  # @return [Object] same as +invoke+
163
174
  # @raise [ArgumentError] when +pool :agent+ has not been configured
175
+ # @api public
164
176
  def stream(team_input, config: {}, &block)
165
177
  return invoke(team_input, config: config) unless block
166
178