phronomy 0.17.0 → 0.19.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 +122 -1134
- data/README.md +70 -1280
- data/benchmark/bench_regression.rb +25 -5
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/009-state-store-abstraction.md +1 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
- data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
- data/docs/features.md +93 -0
- data/docs/getting-started.md +387 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/migrations/0.19.md +148 -0
- data/docs/runtime-and-concurrency.md +349 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +116 -55
- data/lib/phronomy/agent/context/capability/base.rb +13 -3
- data/lib/phronomy/agent/context_assembler.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +423 -252
- data/lib/phronomy/agent/journal_projection.rb +5 -1
- data/lib/phronomy/agent/shared_state.rb +2 -0
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +97 -47
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/configuration.rb +7 -30
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +170 -193
- data/lib/phronomy/engine/fsm_session.rb +6 -4
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +52 -158
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
- data/lib/phronomy/persistence/in_memory.rb +113 -8
- data/lib/phronomy/persistence.rb +12 -3
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +10 -9
- data/lib/phronomy/workflow_runner.rb +363 -99
- data/lib/phronomy.rb +10 -4
- data/scripts/api_snapshot.rb +4 -4
- metadata +25 -33
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -193
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/state_store/base.rb +0 -48
- data/lib/phronomy/state_store/in_memory.rb +0 -62
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Mutable FSM context for one fan-out operation.
|
|
8
|
+
class FanOutInvocation
|
|
9
|
+
Child = Data.define(:index, :agent, :input, :config, :thread_id)
|
|
10
|
+
|
|
11
|
+
attr_reader :id, :phase, :results
|
|
12
|
+
|
|
13
|
+
def initialize(children:, max_concurrency:, on_error:)
|
|
14
|
+
@id = SecureRandom.uuid.to_s
|
|
15
|
+
@phase = nil
|
|
16
|
+
@children = children
|
|
17
|
+
@max_concurrency = max_concurrency || children.length
|
|
18
|
+
@on_error = on_error
|
|
19
|
+
@pending = children.dup
|
|
20
|
+
@active = {}
|
|
21
|
+
@results = Array.new(children.length)
|
|
22
|
+
@child_errors = Array.new(children.length) # indexed by input order
|
|
23
|
+
@fatal_error = nil # driver_failed / timeout / cancel
|
|
24
|
+
@cancelled = false
|
|
25
|
+
@timed_out = false
|
|
26
|
+
@session_id = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def set_graph_metadata(thread_id: nil, phase: nil)
|
|
30
|
+
@session_id = thread_id if thread_id
|
|
31
|
+
@phase = phase
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def handle_fsm_event(event)
|
|
35
|
+
case event.type
|
|
36
|
+
when :child_completed
|
|
37
|
+
payload = event.payload
|
|
38
|
+
index = payload.fetch(:index)
|
|
39
|
+
@active.delete(index)
|
|
40
|
+
child_error = payload[:error]
|
|
41
|
+
if child_error
|
|
42
|
+
if @on_error == :raise
|
|
43
|
+
@child_errors[index] = child_error
|
|
44
|
+
# Signal cancellation so other children can stop early, but continue
|
|
45
|
+
# waiting for all active children to respond so we can return the
|
|
46
|
+
# first error in INPUT ORDER (not arrival order).
|
|
47
|
+
cancel_active_children! unless @child_errors.any?(&:itself)
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
@results[index] = payload[:result]
|
|
51
|
+
end
|
|
52
|
+
true
|
|
53
|
+
when :driver_failed
|
|
54
|
+
@fatal_error ||= event.payload.fetch(:error)
|
|
55
|
+
cancel_active_children!
|
|
56
|
+
true
|
|
57
|
+
when :timeout
|
|
58
|
+
@timed_out = true
|
|
59
|
+
@fatal_error ||= Phronomy::TimeoutError.new(event.payload.fetch(:message))
|
|
60
|
+
cancel_active_children!
|
|
61
|
+
true
|
|
62
|
+
when :cancel
|
|
63
|
+
@cancelled = true
|
|
64
|
+
@fatal_error ||= Phronomy::CancellationError.new("fan-out cancelled")
|
|
65
|
+
cancel_active_children!
|
|
66
|
+
true
|
|
67
|
+
else
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def start_available!(runtime)
|
|
73
|
+
return self if @fatal_error
|
|
74
|
+
|
|
75
|
+
while @active.length < @max_concurrency && (child = @pending.shift)
|
|
76
|
+
child_config = build_child_config(child.config)
|
|
77
|
+
handle = child.agent.invoke_async(
|
|
78
|
+
child.input,
|
|
79
|
+
config: child_config,
|
|
80
|
+
thread_id: child.thread_id
|
|
81
|
+
)
|
|
82
|
+
@active[child.index] = {handle: handle, token: child_config[:cancellation_token]}
|
|
83
|
+
# [child.index].each creates a block parameter with a unique binding
|
|
84
|
+
# per iteration, avoiding the while-loop variable capture problem.
|
|
85
|
+
[child.index].each do |captured_index|
|
|
86
|
+
handle.on_complete do |result, error|
|
|
87
|
+
runtime.event_loop.post_to_session(
|
|
88
|
+
Phronomy::Event.new(
|
|
89
|
+
type: :child_completed,
|
|
90
|
+
target_id: @id,
|
|
91
|
+
payload: {index: captured_index, result: result, error: error}
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
self
|
|
98
|
+
rescue => caught
|
|
99
|
+
@fatal_error ||= caught
|
|
100
|
+
cancel_active_children!
|
|
101
|
+
runtime.event_loop.post_to_session(
|
|
102
|
+
Phronomy::Event.new(
|
|
103
|
+
type: :driver_failed,
|
|
104
|
+
target_id: @id,
|
|
105
|
+
payload: {error: caught}
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def completed?
|
|
112
|
+
!failed? && @pending.empty? && @active.empty?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def failed?
|
|
116
|
+
return true if @fatal_error
|
|
117
|
+
@on_error == :raise && @pending.empty? && @active.empty? && @child_errors.any?(&:itself)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def error
|
|
121
|
+
@fatal_error || @child_errors.find(&:itself)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def timed_out?
|
|
125
|
+
@timed_out
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def cancelled?
|
|
129
|
+
@cancelled
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def build_child_config(config)
|
|
135
|
+
config.dup
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cancel_active_children!
|
|
139
|
+
@pending.clear
|
|
140
|
+
@active.each_value do |entry|
|
|
141
|
+
entry[:token]&.cancel!
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "state_machines"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Builds and registers a fan-out coordination FSMSession.
|
|
8
|
+
class FanOutSessionBuilder
|
|
9
|
+
AUTO_STATE_SET = {idle: true}.freeze
|
|
10
|
+
DECLARED_STATES = %i[idle running completed failed timed_out cancelled].freeze
|
|
11
|
+
WAIT_STATES = [].freeze
|
|
12
|
+
|
|
13
|
+
def self.start(
|
|
14
|
+
invocation:,
|
|
15
|
+
timeout: nil,
|
|
16
|
+
cancellation_token: nil,
|
|
17
|
+
runtime: Phronomy::Runtime.instance
|
|
18
|
+
)
|
|
19
|
+
result = Phronomy::Task.deferred(name: "fan-out:#{invocation.id}")
|
|
20
|
+
|
|
21
|
+
if cancellation_token&.cancelled?
|
|
22
|
+
result.fail(Phronomy::CancellationError.new("fan-out cancelled"))
|
|
23
|
+
return result
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
source = Phronomy::Task.deferred(name: "fan-out-source:#{invocation.id}")
|
|
27
|
+
source.on_complete do |context, error|
|
|
28
|
+
if error
|
|
29
|
+
result.fail(error)
|
|
30
|
+
elsif context.error
|
|
31
|
+
result.fail(context.error)
|
|
32
|
+
else
|
|
33
|
+
result.complete(context.results)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
session = build(invocation: invocation, runtime: runtime)
|
|
38
|
+
runtime.event_loop.register(session, completion: source)
|
|
39
|
+
|
|
40
|
+
if timeout
|
|
41
|
+
runtime.timer_queue.schedule(seconds: timeout) do
|
|
42
|
+
runtime.event_loop.post_to_session(
|
|
43
|
+
Phronomy::Event.new(
|
|
44
|
+
type: :timeout,
|
|
45
|
+
target_id: invocation.id,
|
|
46
|
+
payload: {message: "dispatch_parallel timed out after #{timeout}s"}
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
cancellation_token&.on_cancel do
|
|
53
|
+
runtime.event_loop.post_to_session(
|
|
54
|
+
Phronomy::Event.new(type: :cancel, target_id: invocation.id, payload: nil)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
result
|
|
59
|
+
rescue => error
|
|
60
|
+
result ||= Phronomy::Task.deferred(name: "fan-out:registration")
|
|
61
|
+
result.fail(error)
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.build(invocation:, runtime:)
|
|
66
|
+
phase_machine = build_phase_machine(runtime)
|
|
67
|
+
external_events = {
|
|
68
|
+
child_completed: [{from: :running, to: :running, guard: nil}],
|
|
69
|
+
driver_failed: [{from: :running, to: :failed, guard: nil}],
|
|
70
|
+
timeout: [{from: :running, to: :timed_out, guard: nil}],
|
|
71
|
+
cancel: [{from: :running, to: :cancelled, guard: nil}]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
Phronomy::FSMSession.new(
|
|
75
|
+
id: invocation.id,
|
|
76
|
+
context: invocation,
|
|
77
|
+
entry_point: :idle,
|
|
78
|
+
entry_actions: {},
|
|
79
|
+
auto_state_set: AUTO_STATE_SET,
|
|
80
|
+
declared_states: DECLARED_STATES,
|
|
81
|
+
wait_state_names: WAIT_STATES,
|
|
82
|
+
external_events: external_events,
|
|
83
|
+
phase_machine_class: phase_machine,
|
|
84
|
+
recursion_limit: 10_000,
|
|
85
|
+
event_loop: runtime.event_loop
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :build
|
|
89
|
+
|
|
90
|
+
def self.build_phase_machine(runtime)
|
|
91
|
+
Class.new do
|
|
92
|
+
attr_accessor :context, :current_event
|
|
93
|
+
|
|
94
|
+
state_machine :phase, initial: :idle do
|
|
95
|
+
state :idle
|
|
96
|
+
state :running
|
|
97
|
+
state :completed
|
|
98
|
+
state :failed
|
|
99
|
+
state :timed_out
|
|
100
|
+
state :cancelled
|
|
101
|
+
|
|
102
|
+
event :state_completed do
|
|
103
|
+
transition idle: :running
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
event :child_completed do
|
|
107
|
+
transition running: :failed, if: ->(m) { m.context&.failed? }
|
|
108
|
+
transition running: :completed, if: ->(m) { m.context&.completed? }
|
|
109
|
+
transition running: :running
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
event(:driver_failed) { transition running: :failed }
|
|
113
|
+
event(:timeout) { transition running: :timed_out }
|
|
114
|
+
event(:cancel) { transition running: :cancelled }
|
|
115
|
+
|
|
116
|
+
after_transition to: :running do |machine|
|
|
117
|
+
machine.context.start_available!(runtime)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
private_class_method :build_phase_machine
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -6,19 +6,32 @@ module Phronomy
|
|
|
6
6
|
class Orchestrator < Agent::Base
|
|
7
7
|
agent_definition id: "orchestrator", version: 1
|
|
8
8
|
|
|
9
|
-
# @api public
|
|
10
9
|
def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
|
|
11
|
-
|
|
10
|
+
# A subagent Tool is logically asynchronous: ToolInvocation starts the
|
|
11
|
+
# child Agent and resumes when its completion Task settles. It must not
|
|
12
|
+
# occupy an OffloadPool worker while waiting for the child.
|
|
13
|
+
tool_class = Class.new(Phronomy::Tools::Agent) do
|
|
12
14
|
tool_name "dispatch_to_#{name}"
|
|
13
15
|
description "Dispatch work to the #{name} subagent (#{agent_class.name})"
|
|
14
|
-
param :input, type: :string, desc: "The task or question for the subagent"
|
|
15
16
|
|
|
16
17
|
attr_writer :_orchestrator_context
|
|
17
18
|
|
|
18
|
-
define_method(:execute) do |input
|
|
19
|
+
define_method(:execute) do |input:, cancellation_token: nil|
|
|
20
|
+
execute_async(
|
|
21
|
+
input: input,
|
|
22
|
+
cancellation_token: cancellation_token,
|
|
23
|
+
config: {}
|
|
24
|
+
).wait_result
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
define_method(:execute_async) do |input:, cancellation_token: nil, config: {}|
|
|
19
28
|
ctx = @_orchestrator_context || {}
|
|
20
29
|
parent_ic = ctx[:invocation_context]
|
|
21
|
-
task_config = ctx[:config] || {}
|
|
30
|
+
task_config = (ctx[:config] || {}).merge(config || {})
|
|
31
|
+
|
|
32
|
+
if cancellation_token && !task_config[:cancellation_token]
|
|
33
|
+
task_config = task_config.merge(cancellation_token: cancellation_token)
|
|
34
|
+
end
|
|
22
35
|
|
|
23
36
|
if parent_ic && !task_config[:invocation_context]
|
|
24
37
|
child_ic = parent_ic.merge(parent_task_id: parent_ic.task_id)
|
|
@@ -35,16 +48,30 @@ module Phronomy
|
|
|
35
48
|
end
|
|
36
49
|
end
|
|
37
50
|
|
|
38
|
-
|
|
51
|
+
source = agent.invoke_async(
|
|
39
52
|
input,
|
|
40
53
|
thread_id: ctx[:thread_id] || parent_ic&.thread_id,
|
|
41
54
|
config: task_config
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
)
|
|
56
|
+
result_task = Phronomy::Task.deferred(
|
|
57
|
+
name: "subagent-tool-#{name}"
|
|
58
|
+
)
|
|
59
|
+
source.on_complete do |result, error|
|
|
60
|
+
if error
|
|
61
|
+
(on_error == :raise) ? result_task.fail(error) : result_task.complete(nil)
|
|
62
|
+
else
|
|
63
|
+
result_task.complete(result[:output])
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
result_task
|
|
67
|
+
rescue => error
|
|
68
|
+
result_task ||= Phronomy::Task.deferred(
|
|
69
|
+
name: "subagent-tool-#{name}"
|
|
70
|
+
)
|
|
71
|
+
(on_error == :raise) ? result_task.fail(error) : result_task.complete(nil)
|
|
72
|
+
result_task
|
|
47
73
|
end
|
|
74
|
+
private :execute_async
|
|
48
75
|
end
|
|
49
76
|
|
|
50
77
|
@_subagent_tool_classes = (@_subagent_tool_classes || []) + [tool_class]
|
|
@@ -61,12 +88,10 @@ module Phronomy
|
|
|
61
88
|
@_subagent_tool_classes || []
|
|
62
89
|
end
|
|
63
90
|
|
|
64
|
-
# @api public
|
|
65
91
|
def self.registered_subagents
|
|
66
92
|
@registered_subagents ||= {}
|
|
67
93
|
end
|
|
68
94
|
|
|
69
|
-
# @api public
|
|
70
95
|
def dispatch_parallel(
|
|
71
96
|
*tasks,
|
|
72
97
|
max_concurrency: nil,
|
|
@@ -76,25 +101,52 @@ module Phronomy
|
|
|
76
101
|
invocation_context: nil,
|
|
77
102
|
inherit_knowledge: true
|
|
78
103
|
)
|
|
79
|
-
|
|
80
|
-
raise
|
|
81
|
-
|
|
82
|
-
if max_concurrency && !(max_concurrency.is_a?(Integer) && max_concurrency.positive?)
|
|
83
|
-
raise ArgumentError, "max_concurrency must be a positive Integer"
|
|
104
|
+
if Phronomy::Runtime.in_event_loop_context?
|
|
105
|
+
raise Phronomy::EventLoopReentrancyError,
|
|
106
|
+
"dispatch_parallel cannot block the EventLoop; use dispatch_parallel_async"
|
|
84
107
|
end
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
tasks,
|
|
108
|
+
dispatch_parallel_async(
|
|
109
|
+
*tasks,
|
|
88
110
|
max_concurrency: max_concurrency,
|
|
89
111
|
on_error: on_error,
|
|
90
112
|
timeout: timeout,
|
|
91
113
|
cancellation_token: cancellation_token,
|
|
92
114
|
invocation_context: invocation_context,
|
|
93
115
|
inherit_knowledge: inherit_knowledge
|
|
116
|
+
).wait_result
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def dispatch_parallel_async(
|
|
120
|
+
*tasks,
|
|
121
|
+
max_concurrency: nil,
|
|
122
|
+
on_error: :raise,
|
|
123
|
+
timeout: nil,
|
|
124
|
+
cancellation_token: nil,
|
|
125
|
+
invocation_context: nil,
|
|
126
|
+
inherit_knowledge: true
|
|
127
|
+
)
|
|
128
|
+
validate_parallel_options!(tasks, max_concurrency, on_error)
|
|
129
|
+
return Phronomy::Task.deferred(name: "fan-out-empty").tap { |task| task.complete([]) } if tasks.empty?
|
|
130
|
+
|
|
131
|
+
children = build_fan_out_children(
|
|
132
|
+
tasks,
|
|
133
|
+
cancellation_token: cancellation_token,
|
|
134
|
+
invocation_context: invocation_context,
|
|
135
|
+
inherit_knowledge: inherit_knowledge
|
|
136
|
+
)
|
|
137
|
+
invocation = FanOutInvocation.new(
|
|
138
|
+
children: children,
|
|
139
|
+
max_concurrency: max_concurrency || children.length,
|
|
140
|
+
on_error: on_error
|
|
141
|
+
)
|
|
142
|
+
effective_token = cancellation_token || invocation_context&.cancellation_token
|
|
143
|
+
FanOutSessionBuilder.start(
|
|
144
|
+
invocation: invocation,
|
|
145
|
+
timeout: timeout,
|
|
146
|
+
cancellation_token: effective_token
|
|
94
147
|
)
|
|
95
148
|
end
|
|
96
149
|
|
|
97
|
-
# @api public
|
|
98
150
|
def fan_out(
|
|
99
151
|
agent:,
|
|
100
152
|
inputs:,
|
|
@@ -120,11 +172,31 @@ module Phronomy
|
|
|
120
172
|
)
|
|
121
173
|
end
|
|
122
174
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
175
|
+
def fan_out_async(
|
|
176
|
+
agent:,
|
|
177
|
+
inputs:,
|
|
178
|
+
config: {},
|
|
179
|
+
thread_id: nil,
|
|
180
|
+
max_concurrency: nil,
|
|
181
|
+
on_error: :raise,
|
|
182
|
+
timeout: nil,
|
|
183
|
+
cancellation_token: nil,
|
|
184
|
+
invocation_context: nil,
|
|
185
|
+
inherit_knowledge: true
|
|
186
|
+
)
|
|
187
|
+
dispatch_parallel_async(
|
|
188
|
+
*inputs.map do |input|
|
|
189
|
+
{agent: agent, input: input, config: config, thread_id: thread_id}
|
|
190
|
+
end,
|
|
191
|
+
max_concurrency: max_concurrency,
|
|
192
|
+
on_error: on_error,
|
|
193
|
+
timeout: timeout,
|
|
194
|
+
cancellation_token: cancellation_token,
|
|
195
|
+
invocation_context: invocation_context,
|
|
196
|
+
inherit_knowledge: inherit_knowledge
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
128
200
|
def subagent(
|
|
129
201
|
agent_class,
|
|
130
202
|
input,
|
|
@@ -132,6 +204,10 @@ module Phronomy
|
|
|
132
204
|
thread_id: nil,
|
|
133
205
|
inherit_knowledge: true
|
|
134
206
|
)
|
|
207
|
+
if Phronomy::Runtime.in_event_loop_context?
|
|
208
|
+
raise Phronomy::EventLoopReentrancyError,
|
|
209
|
+
"subagent cannot block the EventLoop; use the async Agent API"
|
|
210
|
+
end
|
|
135
211
|
build_subagent(
|
|
136
212
|
agent_class,
|
|
137
213
|
inherit_knowledge: inherit_knowledge
|
|
@@ -144,8 +220,6 @@ module Phronomy
|
|
|
144
220
|
|
|
145
221
|
private
|
|
146
222
|
|
|
147
|
-
# Capture the current invocation directly while materializing Tool classes.
|
|
148
|
-
# No legacy invoke_once/thread-local bridge is involved.
|
|
149
223
|
def prepare_tool_class(tool_class, invocation: nil)
|
|
150
224
|
prepared = super
|
|
151
225
|
return prepared unless self.class._subagent_tool_classes.include?(tool_class)
|
|
@@ -174,6 +248,10 @@ module Phronomy
|
|
|
174
248
|
self._orchestrator_context = captured_context
|
|
175
249
|
super(args, **kwargs)
|
|
176
250
|
end
|
|
251
|
+
define_method(:call_async) do |args, **kwargs|
|
|
252
|
+
self._orchestrator_context = captured_context
|
|
253
|
+
super(args, **kwargs)
|
|
254
|
+
end
|
|
177
255
|
end
|
|
178
256
|
end
|
|
179
257
|
|
|
@@ -188,11 +266,7 @@ module Phronomy
|
|
|
188
266
|
end.freeze
|
|
189
267
|
end
|
|
190
268
|
|
|
191
|
-
def build_subagent(
|
|
192
|
-
agent_class,
|
|
193
|
-
inherit_knowledge: true,
|
|
194
|
-
knowledge_snapshot: nil
|
|
195
|
-
)
|
|
269
|
+
def build_subagent(agent_class, inherit_knowledge: true, knowledge_snapshot: nil)
|
|
196
270
|
agent = agent_class.new
|
|
197
271
|
return agent unless inherit_knowledge
|
|
198
272
|
|
|
@@ -206,80 +280,54 @@ module Phronomy
|
|
|
206
280
|
agent
|
|
207
281
|
end
|
|
208
282
|
|
|
209
|
-
def
|
|
283
|
+
def validate_parallel_options!(tasks, max_concurrency, on_error)
|
|
284
|
+
unless %i[raise skip].include?(on_error)
|
|
285
|
+
raise ArgumentError, "unknown on_error: #{on_error.inspect}"
|
|
286
|
+
end
|
|
287
|
+
if max_concurrency && !(max_concurrency.is_a?(Integer) && max_concurrency.positive?)
|
|
288
|
+
raise ArgumentError, "max_concurrency must be a positive Integer"
|
|
289
|
+
end
|
|
290
|
+
tasks.each do |task|
|
|
291
|
+
raise ArgumentError, "fan-out task must be a Hash" unless task.is_a?(Hash)
|
|
292
|
+
raise ArgumentError, "fan-out task requires :agent" unless task[:agent]
|
|
293
|
+
raise ArgumentError, "fan-out task requires :input" unless task.key?(:input)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def build_fan_out_children(
|
|
210
298
|
tasks,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
cancellation_token: nil,
|
|
215
|
-
invocation_context: nil,
|
|
216
|
-
inherit_knowledge: true
|
|
299
|
+
cancellation_token:,
|
|
300
|
+
invocation_context:,
|
|
301
|
+
inherit_knowledge:
|
|
217
302
|
)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
inheritance_flags = tasks.map do |task|
|
|
221
|
-
task.fetch(:inherit_knowledge, inherit_knowledge)
|
|
222
|
-
end
|
|
303
|
+
inheritance_flags = tasks.map { |task| task.fetch(:inherit_knowledge, inherit_knowledge) }
|
|
223
304
|
knowledge_snapshot = active_knowledge_snapshot if inheritance_flags.any?
|
|
224
305
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
group.spawn do
|
|
234
|
-
task_config = task.fetch(:config, {})
|
|
235
|
-
|
|
236
|
-
if effective_ct && !task_config[:cancellation_token]
|
|
237
|
-
task_config = task_config.merge(cancellation_token: effective_ct)
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
if invocation_context && !task_config[:invocation_context]
|
|
241
|
-
child_ic = invocation_context.merge(
|
|
242
|
-
parent_task_id: invocation_context.task_id
|
|
243
|
-
)
|
|
244
|
-
task_config = task_config.merge(invocation_context: child_ic)
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
task_inherits_knowledge = inheritance_flags[index]
|
|
248
|
-
agent = build_subagent(
|
|
249
|
-
task[:agent],
|
|
250
|
-
inherit_knowledge: task_inherits_knowledge,
|
|
251
|
-
knowledge_snapshot: knowledge_snapshot
|
|
306
|
+
tasks.each_with_index.map do |task, index|
|
|
307
|
+
task_config = task.fetch(:config, {}).dup
|
|
308
|
+
if cancellation_token && !task_config[:cancellation_token]
|
|
309
|
+
task_config[:cancellation_token] = cancellation_token
|
|
310
|
+
end
|
|
311
|
+
if invocation_context && !task_config[:invocation_context]
|
|
312
|
+
task_config[:invocation_context] = invocation_context.merge(
|
|
313
|
+
parent_task_id: invocation_context.task_id
|
|
252
314
|
)
|
|
253
|
-
|
|
254
|
-
results[index] = agent.invoke_async(
|
|
255
|
-
task[:input],
|
|
256
|
-
config: task_config,
|
|
257
|
-
thread_id: task[:thread_id] || invocation_context&.thread_id
|
|
258
|
-
).wait_result
|
|
259
|
-
rescue => error
|
|
260
|
-
errors[index] = error unless on_error == :skip
|
|
261
315
|
end
|
|
262
|
-
end
|
|
263
316
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
317
|
+
child_agent = build_subagent(
|
|
318
|
+
task.fetch(:agent),
|
|
319
|
+
inherit_knowledge: inheritance_flags[index],
|
|
320
|
+
knowledge_snapshot: knowledge_snapshot
|
|
321
|
+
)
|
|
267
322
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
else
|
|
276
|
-
spawned.each(&:wait_result)
|
|
323
|
+
FanOutInvocation::Child.new(
|
|
324
|
+
index: index,
|
|
325
|
+
agent: child_agent,
|
|
326
|
+
input: task.fetch(:input),
|
|
327
|
+
config: task_config,
|
|
328
|
+
thread_id: task[:thread_id] || invocation_context&.thread_id
|
|
329
|
+
)
|
|
277
330
|
end
|
|
278
|
-
|
|
279
|
-
first_error = errors.compact.first
|
|
280
|
-
raise first_error if first_error
|
|
281
|
-
|
|
282
|
-
results
|
|
283
331
|
end
|
|
284
332
|
end
|
|
285
333
|
end
|
|
@@ -177,6 +177,7 @@ module Phronomy
|
|
|
177
177
|
Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
178
178
|
tool_name "enqueue_task"
|
|
179
179
|
description "Add a task to the worker queue."
|
|
180
|
+
execution_mode :cooperative
|
|
180
181
|
param :description, type: :string, desc: "What the worker agent should do"
|
|
181
182
|
param :metadata, type: :string, desc: "Optional metadata", required: false
|
|
182
183
|
|
|
@@ -197,6 +198,7 @@ module Phronomy
|
|
|
197
198
|
Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
198
199
|
tool_name "finalize"
|
|
199
200
|
description "Signal that task generation is complete. Call this after all tasks have been enqueued."
|
|
201
|
+
execution_mode :cooperative
|
|
200
202
|
param :summary, type: :string, desc: "Brief summary of what was enqueued", required: false
|
|
201
203
|
|
|
202
204
|
define_method(:execute) do |summary: ""|
|