phronomy 0.5.3 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49b20f3defaed56477f9f1ee375a450d26a770d004c052754cc5c045746587cc
4
- data.tar.gz: 1d2fe811e467c7d04208b82cc9d9ca5fca9b17d0bf6061aa952a2fb862c23a53
3
+ metadata.gz: 81df7b877b08caffbfdafb9ab1f1c186739a04ef643a14e7b457be805c8b2b9d
4
+ data.tar.gz: c0fd0ffad64df476c21e0205926df15589c0e654fed9675a6e8aef3589636f1c
5
5
  SHA512:
6
- metadata.gz: 763cf25297e0c8799ad76bcd362ecb5f1899a9ccd0d90791e119d2d0946c59f7c076f7a00d92e01e64735e90b45d7e1aa5462e41efceee9147daf45ac214551f
7
- data.tar.gz: 3a30e9198008dd9e4e512c374324b4c1cfda40c2a41762ff160f90ffa8ac98c0669f700d810e899fa661bd68af3a14db0145a00afc4d0e97d560d7de27989db0
6
+ metadata.gz: cb22a0d7f3edba46a46e9614f4cdad1641941164a641e17c1b3aa24ed07a3d7fb88b408304f1e9c5eaceac02ef8a1fa8503cfb0cffac3ae86b1dd9786756f5ac
7
+ data.tar.gz: 4be7f67215d0b3b8381508f9ccf062fbfc8f41bb7a8a76299e2642634e78421c8ad5fcc551170db4e739c3db7e1cb8fd69ffad6982f50cdba8375f2237aa5ce9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ### Removed
13
+
14
+ - **`Phronomy::Guardrail::Builtin` module removed**: `PromptInjectionDetector`
15
+ and `PIIPatternDetector` are opt-in pattern-matching helpers that encode
16
+ application-level policy decisions (which phrases to block, which PII
17
+ categories to detect, which languages to support). Shipping them as gem
18
+ defaults was misleading — their correct home is inside each application that
19
+ needs them. Reference implementations are now provided in example 06 of
20
+ `phronomy-examples`. Extend `Phronomy::Guardrail::InputGuardrail` directly to
21
+ create equivalent guardrails in your application.
22
+
23
+ ---
24
+
25
+ ## [0.5.4] - 2026-05-20
26
+
27
+ ### New Features
28
+
29
+ - **VectorStore embedding dimension validation** (#98): All three vector store
30
+ implementations (`InMemory`, `RedisSearch`, `Pgvector`) now validate that every
31
+ embedding passed to `add` and `search` matches the expected dimension.
32
+ Dimension is inferred automatically from the first `add` call; alternatively
33
+ it can be set explicitly via `initialize(dimension: N)`. A mismatch raises
34
+ `ArgumentError` with a descriptive message. The `search` method never
35
+ establishes the dimension — it only validates when a dimension is already
36
+ known. `clear` retains the established dimension (schema property).
37
+
38
+ - **`dispatch_parallel` / `fan_out` concurrency controls** (#99): Two new
39
+ keyword arguments are now accepted by both methods.
40
+ - `max_concurrency: nil` (default) or a positive `Integer` — caps the number
41
+ of worker threads. `nil` means one thread per task (previous behaviour).
42
+ - `on_error: :raise` (default) or `:skip` — controls failure handling.
43
+ `:raise` runs all tasks to completion then re-raises the first error in
44
+ input order (fail-last, not fail-fast). `:skip` fills failed slots with
45
+ `nil` and never raises.
46
+ The underlying implementation uses a `Queue`-based bounded worker pool
47
+ (`bounded_map`) for predictable resource usage.
48
+
49
+ ---
50
+
10
51
  ## [0.5.3] - 2026-05-20
11
52
 
12
53
  ### Bug Fixes
data/README.md CHANGED
@@ -12,6 +12,8 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
12
12
  | Feature | Stability |
13
13
  |---|---|
14
14
  | **Workflow** — Stateful, branching workflows with wait_state/send_event | Stable |
15
+ | **Workflow EventLoop Mode** — Opt-in event-driven execution: `Phronomy.configure { \|c\| c.event_loop = true }` | Experimental |
16
+ | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#run_as_child` (child-FSM pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
15
17
  | **Workflow Parallel Node** — Concurrent branches via application-level threads | Beta |
16
18
  | **Agent** — ReAct-style tool-calling agents with guardrails and conversation history | Stable |
17
19
  | **Before-Completion Hook** — Three-tier LLM parameter injection | Stable |
@@ -22,7 +24,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
22
24
  | **Agent::Orchestrator** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
23
25
  | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful worker pool with task queue (worker-local message history per run) | Beta |
24
26
  | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
25
- | **Guardrails** — Input/output validation; built-in PII and prompt-injection detectors | Beta |
27
+ | **Guardrails** — Input/output validation with custom `InputGuardrail`/`OutputGuardrail` | Beta |
26
28
  | **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
27
29
  | **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
28
30
  | **Tracing** — Pluggable span-based observability | Stable |
@@ -83,11 +85,11 @@ app = Phronomy::Workflow.define(ReviewContext) do
83
85
  state :review, action: ->(s) { s.merge(feedback: Reviewer.call(s.draft)) }
84
86
  wait_state :awaiting_approval # halts here for human decision
85
87
  state :finalize, action: ->(s) { s.merge(approved: true) }
86
- after :write, to: :review
87
- after :review, to: :awaiting_approval
88
- after :finalize, to: :__finish__
89
- event :approve, from: :awaiting_approval, to: :finalize
90
- event :reject, from: :awaiting_approval, to: :write
88
+ transition from: :write, to: :review
89
+ transition from: :review, to: :awaiting_approval
90
+ transition from: :finalize, to: :__finish__
91
+ transition from: :awaiting_approval, on: :approve, to: :finalize
92
+ transition from: :awaiting_approval, on: :reject, to: :write
91
93
  end
92
94
 
93
95
  # First run — halts at :awaiting_approval
@@ -146,16 +148,6 @@ agent = ResearchAgent.new
146
148
  agent.add_input_guardrail(NoSensitiveDataGuardrail.new)
147
149
  ```
148
150
 
149
- ### Built-in Guardrails — PII and prompt injection detection
150
-
151
- ```ruby
152
- # Detect SSNs, credit cards, emails, and phone numbers
153
- agent.add_input_guardrail(Phronomy::Guardrail::Builtin::PIIPatternDetector.new)
154
-
155
- # Block common prompt-injection attempts
156
- agent.add_input_guardrail(Phronomy::Guardrail::Builtin::PromptInjectionDetector.new)
157
- ```
158
-
159
151
  ### Knowledge/RAG — Context injection and vector retrieval
160
152
 
161
153
  ```ruby
@@ -271,10 +263,11 @@ end
271
263
 
272
264
  ### Agent::Orchestrator — Parallel subagent dispatch
273
265
 
274
- > **Note:** `dispatch_parallel` and `fan_out` use plain Ruby threads and are
275
- > intended for small-scale fan-out (a handful of subagents). For large-scale
276
- > parallel dispatch, manage concurrency (thread pools, rate limiting) at the
277
- > application level.
266
+ > **Note:** `dispatch_parallel` and `fan_out` use plain Ruby threads. Use
267
+ > `max_concurrency:` to cap the number of concurrent workers and `on_error:`
268
+ > to control failure handling (`:raise` re-raises the first error after all
269
+ > tasks complete; `:skip` fills failed slots with `nil`). For very large
270
+ > fan-outs consider additional rate-limiting at the application level.
278
271
 
279
272
  ```ruby
280
273
  class ResearchOrchestrator < Phronomy::Agent::Orchestrator
@@ -297,16 +290,22 @@ class MyOrchestrator < Phronomy::Agent::Orchestrator
297
290
  instructions "Orchestrate."
298
291
 
299
292
  def run(query)
300
- # Heterogeneous agents in parallel
293
+ # Heterogeneous agents in parallel (cap at 4 threads; skip failures)
301
294
  results = dispatch_parallel(
302
295
  {agent: SearchAgent, input: "topic A"},
303
- {agent: AnalysisAgent, input: query}
296
+ {agent: AnalysisAgent, input: query},
297
+ max_concurrency: 4,
298
+ on_error: :skip
304
299
  )
305
300
 
306
301
  # Fan-out — same agent, multiple inputs
307
- translations = fan_out(agent: TranslationAgent, inputs: %w[Hello World])
302
+ translations = fan_out(
303
+ agent: TranslationAgent,
304
+ inputs: %w[Hello World],
305
+ max_concurrency: 2
306
+ )
308
307
 
309
- results.map { |r| r[:output] }.join("\n")
308
+ results.compact.map { |r| r[:output] }.join("\n")
310
309
  end
311
310
  end
312
311
  ```
@@ -333,7 +332,7 @@ app = Phronomy::Workflow.define(EnrichContext) do
333
332
  threads.each { |t| t.join(10) } # 10-second timeout
334
333
  s.merge(summary: results[:summary], tags: Array(results[:tags]))
335
334
  end
336
- after :enrich, to: :__finish__
335
+ transition from: :enrich, to: :__finish__
337
336
  end
338
337
 
339
338
  state = app.invoke({}, config: { thread_id: "t1" })
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "securerandom"
4
5
  require_relative "concerns/retryable"
5
6
  require_relative "concerns/guardrailable"
6
7
  require_relative "concerns/before_completion"
@@ -382,7 +383,82 @@ module Phronomy
382
383
  # end
383
384
  # puts result[:output]
384
385
  def invoke(input, messages: [], thread_id: nil, config: {})
385
- _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
386
+ if Phronomy.configuration.event_loop
387
+ # Protect against blocking the EventLoop thread itself.
388
+ if Thread.current[:phronomy_event_loop_thread]
389
+ raise Phronomy::Error,
390
+ "Cannot call Agent#invoke (EventLoop mode) from within an EventLoop " \
391
+ "entry action. Use agent.run_as_child(input, ctx: ctx) instead."
392
+ end
393
+
394
+ fsm = Agent::FSM.new(
395
+ agent: self,
396
+ input: input,
397
+ messages: messages,
398
+ thread_id: thread_id || SecureRandom.uuid,
399
+ config: config
400
+ )
401
+ completion_queue = Phronomy::EventLoop.instance.register(fsm)
402
+ result = completion_queue.pop
403
+ raise result if result.is_a?(Exception)
404
+ result
405
+ else
406
+ _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
407
+ end
408
+ end
409
+
410
+ # Registers this agent as a child {AgentFSM} inside the given Workflow context.
411
+ #
412
+ # Use this method from a Workflow entry action (running on the EventLoop thread)
413
+ # instead of {#invoke}, which would raise a deadlock error because +invoke+ blocks
414
+ # on a +Thread::Queue+ when EventLoop mode is active.
415
+ #
416
+ # The agent runs asynchronously in a background IO thread. When it finishes, the
417
+ # parent {FSMSession} receives a +:child_completed+ event whose payload is the
418
+ # result hash +{ output:, messages:, usage: }+. Declare an +on: :child_completed+
419
+ # transition in your Workflow to advance to the next state.
420
+ #
421
+ # An optional block may be provided to write the result back into the parent
422
+ # WorkflowContext <b>before</b> the +:child_completed+ event is dispatched.
423
+ # +Thread::Queue+ provides the happens-before guarantee \u2014 no Mutex is needed.
424
+ #
425
+ # @example Without block (result available only as event payload)
426
+ # entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
427
+ # transition from: :run_agent, on: :child_completed, to: :process_result
428
+ #
429
+ # @example With block (writes result into context)
430
+ # entry :run_agent, ->(ctx) {
431
+ # MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
432
+ # }
433
+ # transition from: :run_agent, on: :child_completed, to: :process_result
434
+ #
435
+ # @param input [String, Hash] user input passed to the agent
436
+ # @param ctx [Object] a WorkflowContext that responds to +#thread_id+
437
+ # @param messages [Array] prior conversation history
438
+ # @param config [Hash] invocation config (forwarded to +_invoke_impl+)
439
+ # @yield [Hash] result hash +{ output:, messages:, usage: }+ — called from the
440
+ # agent IO thread before +:child_completed+ is posted
441
+ # @return [nil] the caller must not wait on any return value;
442
+ # the result arrives as a +:child_completed+ event
443
+ # @raise [Phronomy::Error] when EventLoop mode is not enabled
444
+ def run_as_child(input, ctx:, messages: [], config: {}, &result_writer)
445
+ unless Phronomy.configuration.event_loop
446
+ raise Phronomy::Error,
447
+ "run_as_child requires EventLoop mode. " \
448
+ "Enable with: Phronomy.configure { |c| c.event_loop = true }"
449
+ end
450
+
451
+ fsm = Agent::FSM.new(
452
+ agent: self,
453
+ input: input,
454
+ messages: messages,
455
+ thread_id: "#{ctx.thread_id}_agent_#{SecureRandom.uuid}",
456
+ config: config,
457
+ parent_id: ctx.thread_id,
458
+ result_writer: result_writer
459
+ )
460
+ Phronomy::EventLoop.instance.enqueue_child(fsm)
461
+ nil
386
462
  end
387
463
 
388
464
  # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
@@ -665,6 +741,15 @@ module Phronomy
665
741
 
666
742
  # Load messages from a ConversationManager.
667
743
  #
744
+ # Returns the chat class to instantiate for this invocation.
745
+ # When the +:phronomy_agent_parallel_tools+ thread-local flag is set
746
+ # (i.e. inside an {AgentFSM} IO thread), returns {ParallelToolChat} so
747
+ # that concurrent tool dispatch is enabled. Falls back to +nil+ otherwise,
748
+ # signalling {#build_chat} to use the standard +RubyLLM.chat+ factory.
749
+ def build_chat_class
750
+ Thread.current[:phronomy_agent_parallel_tools] ? Agent::ParallelToolChat : nil
751
+ end
752
+
668
753
  def build_chat
669
754
  opts = {}
670
755
  m = self.class.model
@@ -675,7 +760,8 @@ module Phronomy
675
760
  opts[:assume_model_exists] = true
676
761
  end
677
762
  t = self.class.temperature
678
- chat = RubyLLM.chat(**opts)
763
+ parallel_class = build_chat_class
764
+ chat = parallel_class ? parallel_class.new(**opts) : RubyLLM.chat(**opts)
679
765
  chat.with_temperature(t) if t
680
766
  self.class.tools.each do |tool_class|
681
767
  chat.with_tool(prepare_tool_class(tool_class))
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # EventLoop-registered execution unit for a single agent invocation.
8
+ #
9
+ # +AgentFSM+ implements the minimal interface expected by {Phronomy::EventLoop}
10
+ # (+#id+, +#start+, +#handle+) so it can be managed alongside
11
+ # {Phronomy::FSMSession} instances. It is *not* a traditional finite-state
12
+ # machine; the name reflects its role in the EventLoop rather than internal
13
+ # state transitions.
14
+ #
15
+ # == Execution model
16
+ #
17
+ # {#start} is called by the EventLoop on the +:start+ event. It immediately
18
+ # returns after spawning a background IO thread that runs the agent's full
19
+ # invocation pipeline (via +_invoke_impl+). The EventLoop thread is never
20
+ # blocked by agent execution.
21
+ #
22
+ # Inside the IO thread, the +:phronomy_agent_parallel_tools+ thread-local
23
+ # flag is set to +true+ so that {Agent::Base#build_chat} returns a
24
+ # {ParallelToolChat} instance, enabling concurrent tool dispatch when the LLM
25
+ # returns multiple tool calls in one response.
26
+ #
27
+ # == Completion events
28
+ #
29
+ # On *success*:
30
+ # - Posts +:finished+ to this FSM's own +#id+ so the EventLoop cleans up
31
+ # its registry entry and unblocks any +completion_queue.pop+ caller.
32
+ # - When +parent_id+ is set (child-FSM pattern), additionally posts
33
+ # +:child_completed+ to +parent_id+, carrying the result hash as the
34
+ # event payload. The parent {FSMSession} must declare an +on:+ transition
35
+ # for +:child_completed+ to advance correctly.
36
+ #
37
+ # On *error*:
38
+ # - Posts +:error+ to this FSM's own +#id+. The EventLoop propagates the
39
+ # exception through the +completion_queue+ so that the original caller of
40
+ # +Agent::Base#invoke+ (in EventLoop mode) receives and re-raises it.
41
+ #
42
+ # == Standalone usage (blocking caller)
43
+ #
44
+ # Phronomy.configure { |c| c.event_loop = true }
45
+ # result = MyAgent.new.invoke("Hello!") # => { output:, messages:, usage: }
46
+ #
47
+ # {Agent::Base#invoke} detects EventLoop mode, creates an +AgentFSM+, registers
48
+ # it via {EventLoop#register}, and blocks the *calling* thread on the returned
49
+ # +completion_queue+ until the agent finishes.
50
+ #
51
+ # == Child-FSM usage (non-blocking, inside a Workflow)
52
+ #
53
+ # state :run_agent
54
+ # entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
55
+ # transition from: :run_agent, on: :child_completed, to: :process_result
56
+ #
57
+ # {Agent::Base#run_as_child} creates an +AgentFSM+ with +parent_id+ set to
58
+ # +ctx.thread_id+, registers it with the EventLoop, and returns immediately.
59
+ # The parent {FSMSession} waits for the +:child_completed+ event.
60
+ class FSM
61
+ # @return [String] unique identifier used as the EventLoop target_id
62
+ attr_reader :id
63
+
64
+ # @return [Symbol] current internal phase (:idle, :running)
65
+ attr_reader :current_phase
66
+
67
+ # @param agent [Phronomy::Agent::Base] agent instance to run
68
+ # @param input [String, Hash] user input passed to +invoke_once+
69
+ # @param messages [Array] prior conversation history
70
+ # @param thread_id [String, nil] conversation thread id;
71
+ # auto-generated when nil
72
+ # @param config [Hash] invocation config forwarded to
73
+ # +_invoke_impl+
74
+ # @param parent_id [String, nil] EventLoop id of the parent
75
+ # FSMSession; when set, a
76
+ # +:child_completed+ event is posted
77
+ # on completion
78
+ # @param result_writer [Proc, nil] optional callable invoked with the
79
+ # result hash <b>before</b>
80
+ # +:child_completed+ is posted.
81
+ # Use this to write the agent output
82
+ # back into the parent WorkflowContext.
83
+ # Thread::Queue provides the
84
+ # happens-before guarantee.
85
+ #
86
+ # @example Writing result into context
87
+ # entry :run_agent, ->(ctx) {
88
+ # MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
89
+ # }
90
+ def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil, result_writer: nil)
91
+ @agent = agent
92
+ @input = input
93
+ @messages = Array(messages).dup
94
+ @thread_id = thread_id || SecureRandom.uuid
95
+ @config = config
96
+ @parent_id = parent_id
97
+ @result_writer = result_writer
98
+ @id = @thread_id
99
+ @current_phase = :idle
100
+ end
101
+
102
+ # Called by {EventLoop} on the +:start+ event.
103
+ # Transitions to +:running+ and spawns the agent IO thread.
104
+ def start
105
+ @current_phase = :running
106
+ spawn_agent_thread
107
+ end
108
+
109
+ # Called by {EventLoop} for external events dispatched to this id.
110
+ # +AgentFSM+ is fully driven by its own IO thread and does not respond
111
+ # to external events after {#start}.
112
+ def handle(_event)
113
+ # No-op: AgentFSM is driven entirely by its IO thread.
114
+ end
115
+
116
+ private
117
+
118
+ # Spawns the background IO thread that runs the agent invocation.
119
+ # Captures all instance variables by value so the thread closure is
120
+ # safe even if the FSM object is modified (though it is not in practice).
121
+ def spawn_agent_thread
122
+ agent = @agent
123
+ input = @input
124
+ messages = @messages
125
+ thread_id = @thread_id
126
+ config = @config
127
+ fsm_id = @id
128
+ parent_id = @parent_id
129
+ result_writer = @result_writer
130
+
131
+ Thread.new do
132
+ # Enable parallel tool dispatch inside this IO thread.
133
+ Thread.current[:phronomy_agent_parallel_tools] = true
134
+
135
+ begin
136
+ result = agent.send(:_invoke_impl,
137
+ input,
138
+ messages: messages,
139
+ thread_id: thread_id,
140
+ config: config)
141
+
142
+ if parent_id
143
+ # Let the caller write the result into the context BEFORE the
144
+ # parent FSMSession advances. Thread::Queue provides the
145
+ # happens-before guarantee — no Mutex needed.
146
+ result_writer&.call(result)
147
+
148
+ Phronomy::EventLoop.instance.post(
149
+ Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
150
+ )
151
+ end
152
+
153
+ Phronomy::EventLoop.instance.post(
154
+ Phronomy::Event.new(type: :finished, target_id: fsm_id, payload: result)
155
+ )
156
+ rescue => e
157
+ Phronomy::EventLoop.instance.post(
158
+ Phronomy::Event.new(type: :error, target_id: fsm_id, payload: e)
159
+ )
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -88,31 +88,112 @@ module Phronomy
88
88
  # threads. Each task is a Hash describing one agent invocation.
89
89
  #
90
90
  # Results are returned in the same order as the input +tasks+ array.
91
- # If any thread raises an exception, the exception is re-raised in the
92
- # calling thread after all threads have completed (via +Thread#value+).
91
+ # Concurrency is bounded by +max_concurrency+; when nil all tasks run at
92
+ # once (original behaviour).
93
93
  #
94
- # @param tasks [Array<Hash>]
95
- # @option task [Class] :agent agent class to invoke (required)
96
- # @option task [String] :input input string for the agent (required)
97
- # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
98
- # @return [Array<Hash>] agent results in the same order as +tasks+
99
- def dispatch_parallel(*tasks)
100
- threads = tasks.map do |task|
101
- Thread.new do
102
- task[:agent].new.invoke(task[:input], config: task.fetch(:config, {}))
103
- end
94
+ # Error semantics are controlled by +on_error+:
95
+ # - +:raise+ (default) — every task runs to completion; the first
96
+ # exception in input order is then re-raised in the calling thread.
97
+ # - +:skip+ — failed tasks return +nil+; no exception is raised.
98
+ #
99
+ # @param tasks [Array<Hash>]
100
+ # @option task [Class] :agent agent class to invoke (required)
101
+ # @option task [String] :input input string for the agent (required)
102
+ # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
103
+ # @param max_concurrency [Integer, nil] maximum number of concurrent threads;
104
+ # nil means no limit (all tasks run simultaneously)
105
+ # @param on_error [Symbol] +:raise+ or +:skip+
106
+ # @return [Array<Hash, nil>] agent results in the same order as +tasks+
107
+ # @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
108
+ # @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
109
+ def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise)
110
+ unless [:raise, :skip].include?(on_error)
111
+ raise ArgumentError, "unknown on_error: #{on_error.inspect}"
104
112
  end
105
- threads.map(&:value)
113
+ if max_concurrency && !(max_concurrency.is_a?(Integer) && max_concurrency.positive?)
114
+ raise ArgumentError, "max_concurrency must be a positive Integer"
115
+ end
116
+
117
+ bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error)
106
118
  end
107
119
 
108
120
  # Runs the same agent against multiple inputs in parallel (fan-out pattern).
109
121
  #
110
- # @param agent [Class] agent class to invoke for every input
111
- # @param inputs [Array<String>] list of input strings
112
- # @param config [Hash] forwarded to every +agent#invoke+ call
113
- # @return [Array<Hash>] results in the same order as +inputs+
114
- def fan_out(agent:, inputs:, config: {})
115
- dispatch_parallel(*inputs.map { |input| {agent: agent, input: input, config: config} })
122
+ # Accepts the same +max_concurrency:+ and +on_error:+ keyword arguments as
123
+ # {#dispatch_parallel} and forwards them unchanged.
124
+ #
125
+ # @param agent [Class] agent class to invoke for every input
126
+ # @param inputs [Array<String>] list of input strings
127
+ # @param config [Hash] forwarded to every +agent#invoke+ call
128
+ # @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
129
+ # @param on_error [Symbol] forwarded to {#dispatch_parallel}
130
+ # @return [Array<Hash, nil>] results in the same order as +inputs+
131
+ def fan_out(agent:, inputs:, config: {}, max_concurrency: nil, on_error: :raise)
132
+ dispatch_parallel(
133
+ *inputs.map { |input| {agent: agent, input: input, config: config} },
134
+ max_concurrency: max_concurrency,
135
+ on_error: on_error
136
+ )
137
+ end
138
+
139
+ private
140
+
141
+ # Worker-pool implementation shared by {#dispatch_parallel} and {#fan_out}.
142
+ #
143
+ # Uses a +Queue+ as a work-stealing mechanism: each worker thread pops a
144
+ # task, executes it, and loops until the queue is empty. The number of
145
+ # workers is +min(max_concurrency, tasks.length)+, capped at the task count
146
+ # so we never spin up idle threads.
147
+ #
148
+ # +errors+ is indexed by task position so that the first error in *input*
149
+ # order is deterministically re-raised when +on_error: :raise+ is used.
150
+ # A +Mutex+ guards concurrent writes to +errors+ even though Array element
151
+ # assignment at different indices is safe in MRI; this keeps the code
152
+ # correct across alternative Ruby runtimes.
153
+ def bounded_map(tasks, max_concurrency:, on_error:)
154
+ return [] if tasks.empty?
155
+
156
+ results = Array.new(tasks.length)
157
+ errors = Array.new(tasks.length)
158
+ errors_mutex = Mutex.new
159
+
160
+ queue = Queue.new
161
+ tasks.each_with_index { |task, i| queue << [i, task] }
162
+
163
+ worker_count = [max_concurrency || tasks.length, tasks.length].min
164
+
165
+ workers = worker_count.times.map do
166
+ Thread.new do
167
+ loop do
168
+ i, task = begin
169
+ queue.pop(true)
170
+ rescue ThreadError
171
+ break # queue is empty; this worker is done
172
+ end
173
+
174
+ begin
175
+ results[i] = task[:agent].new.invoke(
176
+ task[:input],
177
+ config: task.fetch(:config, {})
178
+ )
179
+ rescue => e
180
+ case on_error
181
+ when :skip
182
+ results[i] = nil
183
+ else
184
+ errors_mutex.synchronize { errors[i] = e }
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ workers.each(&:join)
192
+
193
+ first_error = errors.compact.first
194
+ raise first_error if first_error
195
+
196
+ results
116
197
  end
117
198
  end
118
199
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # RubyLLM::Chat subclass that executes multiple tool calls concurrently.
6
+ #
7
+ # When the LLM returns more than one tool call in a single response, each
8
+ # tool is dispatched in a dedicated IO thread and all results are collected
9
+ # before being appended to the message history. This preserves a
10
+ # deterministic message order while reducing wall-clock latency when tools
11
+ # are IO-bound (HTTP calls, DB queries, etc.).
12
+ #
13
+ # Single-tool responses fall through to the standard sequential path via
14
+ # +super+, preserving all existing edge-case behaviour (Tool::Halt,
15
+ # forced_tool_choice, streaming, SuspendSignal, etc.).
16
+ #
17
+ # This class is used automatically when the agent is running inside an
18
+ # {AgentFSM} IO thread (i.e. when the +:phronomy_agent_parallel_tools+
19
+ # thread-local flag is +true+). It is not used for direct synchronous
20
+ # +invoke+ calls so that the streaming callback state remains single-threaded.
21
+ class ParallelToolChat < RubyLLM::Chat
22
+ private
23
+
24
+ # Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
25
+ # when multiple tool calls are present in a single LLM response.
26
+ #
27
+ # The method preserves the three-phase protocol of the original:
28
+ # 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
29
+ # sequential so that the Suspendable concern's approval hook can
30
+ # raise +SuspendSignal+ before any tool is executed.
31
+ # 2. Parallel tool execution — one IO thread per tool call.
32
+ # 3. Post-execution callbacks and message recording — sequential,
33
+ # in the original tool-call order.
34
+ #
35
+ # @param response [RubyLLM::Message] the LLM response containing tool calls
36
+ # @yield streaming block forwarded to +complete+
37
+ def handle_tool_calls(response, &block)
38
+ tool_calls = response.tool_calls.values
39
+
40
+ # Single tool: delegate to the parent implementation to preserve every
41
+ # edge case (forced_tool_choice, streaming, Halt, SuspendSignal…).
42
+ return super if tool_calls.size <= 1
43
+
44
+ # Phase 1 — pre-execution callbacks (sequential, original order).
45
+ # The SuspendSignal approval hook is registered via on_tool_call, so it
46
+ # MUST fire before execution begins.
47
+ tool_calls.each do |tool_call|
48
+ @on[:new_message]&.call
49
+ @on[:tool_call]&.call(tool_call)
50
+ end
51
+
52
+ # 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
+ end
56
+ results = thread_results.map(&:value)
57
+
58
+ # Phase 3 — post-execution callbacks and message recording (sequential).
59
+ halt_result = nil
60
+ results.each do |item|
61
+ result = item[:result]
62
+ @on[:tool_result]&.call(result)
63
+ tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
64
+ content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
65
+ message = add_message(role: :tool, content: content, tool_call_id: item[:tool_call].id)
66
+ @on[:end_message]&.call(message)
67
+ halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
68
+ end
69
+
70
+ reset_tool_choice if forced_tool_choice?
71
+ halt_result || complete(&block)
72
+ end
73
+ end
74
+ end
75
+ end