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 +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +24 -25
- data/lib/phronomy/agent/base.rb +88 -2
- data/lib/phronomy/agent/fsm.rb +165 -0
- data/lib/phronomy/agent/orchestrator.rb +100 -19
- data/lib/phronomy/agent/parallel_tool_chat.rb +75 -0
- data/lib/phronomy/configuration.rb +6 -0
- data/lib/phronomy/context.rb +0 -1
- data/lib/phronomy/event.rb +14 -0
- data/lib/phronomy/event_loop.rb +147 -0
- data/lib/phronomy/fsm_session.rb +194 -0
- data/lib/phronomy/generator_verifier.rb +22 -22
- data/lib/phronomy/guardrail.rb +0 -1
- data/lib/phronomy/vector_store/base.rb +15 -0
- data/lib/phronomy/vector_store/in_memory.rb +11 -1
- data/lib/phronomy/vector_store/pgvector.rb +8 -2
- data/lib/phronomy/vector_store/redis_search.rb +16 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +83 -71
- data/lib/phronomy/workflow_context.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +167 -112
- data/lib/phronomy.rb +4 -0
- metadata +7 -6
- data/lib/phronomy/context/builder.rb +0 -92
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
- data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
- data/lib/phronomy/guardrail/builtin.rb +0 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81df7b877b08caffbfdafb9ab1f1c186739a04ef643a14e7b457be805c8b2b9d
|
|
4
|
+
data.tar.gz: c0fd0ffad64df476c21e0205926df15589c0e654fed9675a6e8aef3589636f1c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
275
|
-
>
|
|
276
|
-
>
|
|
277
|
-
>
|
|
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(
|
|
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
|
-
|
|
335
|
+
transition from: :enrich, to: :__finish__
|
|
337
336
|
end
|
|
338
337
|
|
|
339
338
|
state = app.invoke({}, config: { thread_id: "t1" })
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
92
|
-
#
|
|
91
|
+
# Concurrency is bounded by +max_concurrency+; when nil all tasks run at
|
|
92
|
+
# once (original behaviour).
|
|
93
93
|
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
# @
|
|
114
|
-
|
|
115
|
-
|
|
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
|