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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -1134
  3. data/README.md +70 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/009-state-store-abstraction.md +1 -1
  8. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  9. data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
  10. data/docs/features.md +93 -0
  11. data/docs/getting-started.md +387 -0
  12. data/docs/migrations/0.15.md +35 -0
  13. data/docs/migrations/0.16.md +43 -0
  14. data/docs/migrations/0.19.md +148 -0
  15. data/docs/runtime-and-concurrency.md +349 -0
  16. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  17. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  18. data/lib/phronomy/agent/async_event_api.rb +3 -3
  19. data/lib/phronomy/agent/base.rb +116 -55
  20. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  21. data/lib/phronomy/agent/context_assembler.rb +13 -3
  22. data/lib/phronomy/agent/execution_coordinator.rb +423 -252
  23. data/lib/phronomy/agent/journal_projection.rb +5 -1
  24. data/lib/phronomy/agent/shared_state.rb +2 -0
  25. data/lib/phronomy/agent/tool_executor.rb +29 -71
  26. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  27. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  28. data/lib/phronomy/configuration.rb +7 -30
  29. data/lib/phronomy/diagnostics.rb +12 -41
  30. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  31. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  32. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  33. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  34. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  35. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  36. data/lib/phronomy/engine/event_loop.rb +170 -193
  37. data/lib/phronomy/engine/fsm_session.rb +6 -4
  38. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  39. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  40. data/lib/phronomy/engine/runtime.rb +52 -158
  41. data/lib/phronomy/engine/task.rb +136 -277
  42. data/lib/phronomy/llm_adapter/base.rb +14 -14
  43. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  44. data/lib/phronomy/llm_adapter.rb +2 -2
  45. data/lib/phronomy/metrics.rb +15 -30
  46. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  47. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  48. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  49. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  50. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  51. data/lib/phronomy/persistence/in_memory.rb +113 -8
  52. data/lib/phronomy/persistence.rb +12 -3
  53. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  54. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  55. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  56. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  57. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  58. data/lib/phronomy/testing/eval/runner.rb +52 -0
  59. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  60. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  61. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  62. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  63. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  64. data/lib/phronomy/testing/eval.rb +9 -0
  65. data/lib/phronomy/testing/fake_clock.rb +6 -53
  66. data/lib/phronomy/testing.rb +2 -6
  67. data/lib/phronomy/tools/agent.rb +141 -6
  68. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  69. data/lib/phronomy/vector_store/base.rb +2 -2
  70. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  71. data/lib/phronomy/version.rb +1 -1
  72. data/lib/phronomy/workflow.rb +10 -9
  73. data/lib/phronomy/workflow_runner.rb +363 -99
  74. data/lib/phronomy.rb +10 -4
  75. data/scripts/api_snapshot.rb +4 -4
  76. metadata +25 -33
  77. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  78. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  79. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  80. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  81. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  82. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  83. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  84. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  85. data/lib/phronomy/engine/task/backend.rb +0 -80
  86. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  87. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  88. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  89. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  90. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  91. data/lib/phronomy/engine/task_group.rb +0 -193
  92. data/lib/phronomy/eval/comparison.rb +0 -47
  93. data/lib/phronomy/eval/dataset.rb +0 -45
  94. data/lib/phronomy/eval/eval_case.rb +0 -17
  95. data/lib/phronomy/eval/eval_result.rb +0 -29
  96. data/lib/phronomy/eval/metrics.rb +0 -66
  97. data/lib/phronomy/eval/runner.rb +0 -94
  98. data/lib/phronomy/eval/scorer/base.rb +0 -22
  99. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  100. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  101. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  102. data/lib/phronomy/eval/scorer.rb +0 -9
  103. data/lib/phronomy/eval.rb +0 -7
  104. data/lib/phronomy/state_store/base.rb +0 -48
  105. data/lib/phronomy/state_store/in_memory.rb +0 -62
  106. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  107. 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
@@ -42,6 +42,7 @@ module Phronomy
42
42
  Class.new(Phronomy::Agent::Context::Capability::Base) do
43
43
  tool_name tn
44
44
  description desc
45
+ execution_mode :cooperative
45
46
  define_method(:execute) { sentinel_value }
46
47
  end
47
48
  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
- tool_class = Class.new(Phronomy::Agent::Context::Capability::Base) do
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
- result = agent.invoke_async(
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
- ).wait_result
43
- result[:output]
44
- rescue
45
- raise if on_error == :raise
46
- nil
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
- unless %i[raise skip].include?(on_error)
80
- raise ArgumentError, "unknown on_error: #{on_error.inspect}"
81
- end
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
- bounded_map(
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
- # Programmatic single-subagent dispatch. Context propagation is explicit:
124
- # pass invocation_context in +config+ when this call must inherit a parent.
125
- # Active parent Knowledge is inherited by default; pass
126
- # +inherit_knowledge: false+ to create an isolated subagent.
127
- # @api public
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 bounded_map(
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
- max_concurrency:,
212
- on_error:,
213
- timeout: nil,
214
- cancellation_token: nil,
215
- invocation_context: nil,
216
- inherit_knowledge: true
299
+ cancellation_token:,
300
+ invocation_context:,
301
+ inherit_knowledge:
217
302
  )
218
- return [] if tasks.empty?
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
- results = Array.new(tasks.length)
226
- errors = Array.new(tasks.length)
227
- group = Phronomy::Runtime.instance.task_group(
228
- limit: max_concurrency || tasks.length
229
- )
230
- effective_ct = cancellation_token || invocation_context&.cancellation_token
231
-
232
- spawned = tasks.each_with_index.map do |task, index|
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
- if timeout
265
- deadline = Phronomy::Concurrency::Deadline.in(timeout)
266
- spawned.each { |task| task.join([deadline.remaining_seconds, 0].max) }
317
+ child_agent = build_subagent(
318
+ task.fetch(:agent),
319
+ inherit_knowledge: inheritance_flags[index],
320
+ knowledge_snapshot: knowledge_snapshot
321
+ )
267
322
 
268
- alive = spawned.select(&:alive?)
269
- unless alive.empty?
270
- group.cancel_all!
271
- raise Phronomy::TimeoutError,
272
- "dispatch_parallel timed out after #{timeout}s " \
273
- "(#{alive.length} of #{spawned.length} tasks still running)"
274
- end
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: ""|