phronomy 0.6.0 → 0.7.1

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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. metadata +83 -2
@@ -55,14 +55,36 @@ module Phronomy
55
55
  # @param on_error [Symbol] +:raise+ (default) re-raises any exception
56
56
  # from the subagent; +:skip+ returns +nil+ so the LLM can decide how to
57
57
  # proceed
58
+ # @api public
58
59
  def self.subagent(name, agent_class, on_error: :raise)
59
60
  tool_class = Class.new(Phronomy::Tool::Base) do
60
61
  tool_name "dispatch_to_#{name}"
61
62
  description "Dispatch work to the #{name} subagent (#{agent_class.name})"
62
63
  param :input, type: :string, desc: "The task or question for the subagent"
63
64
 
65
+ # @_orchestrator_context is injected at call time by prepare_tool_class.
66
+ attr_writer :_orchestrator_context
67
+
64
68
  define_method(:execute) do |input:|
65
- result = agent_class.new.invoke(input)
69
+ # Inherit the calling orchestrator's thread_id, config, and
70
+ # InvocationContext so that child subagent spans and memory stay
71
+ # connected to the parent invocation.
72
+ ctx = @_orchestrator_context || {}
73
+ parent_ic = ctx[:invocation_context]
74
+ task_config = ctx[:config] || {}
75
+
76
+ # Propagate parent InvocationContext to the child agent so that
77
+ # cancellation, deadline, and tracing carry through automatically.
78
+ if parent_ic && !task_config[:invocation_context]
79
+ child_ic = parent_ic.merge(parent_task_id: parent_ic.task_id)
80
+ task_config = task_config.merge(invocation_context: child_ic)
81
+ end
82
+
83
+ result = agent_class.new.invoke_async(
84
+ input,
85
+ thread_id: ctx[:thread_id] || parent_ic&.thread_id,
86
+ config: task_config
87
+ ).await
66
88
  result[:output]
67
89
  rescue
68
90
  raise if on_error == :raise
@@ -70,6 +92,9 @@ module Phronomy
70
92
  end
71
93
  end
72
94
 
95
+ # Track this tool class so prepare_tool_class can inject context.
96
+ @_subagent_tool_classes = (@_subagent_tool_classes || []) + [tool_class]
97
+
73
98
  # Append without clobbering previously registered tools or aliases.
74
99
  @tools = (@tools || []) + [tool_class]
75
100
  @tool_aliases ||= {}
@@ -77,15 +102,24 @@ module Phronomy
77
102
  registered_subagents[name] = {agent_class: agent_class, on_error: on_error}
78
103
  end
79
104
 
105
+ # Returns the subagent tool classes registered on this specific class.
106
+ # Used by {#prepare_tool_class} to inject context.
107
+ # @return [Array<Class>]
108
+ # @api private
109
+ def self._subagent_tool_classes
110
+ @_subagent_tool_classes || []
111
+ end
112
+
80
113
  # Returns the subagent registry for this specific class (not inherited).
81
114
  #
82
115
  # @return [Hash{Symbol => Hash}]
116
+ # @api public
83
117
  def self.registered_subagents
84
118
  @registered_subagents ||= {}
85
119
  end
86
120
 
87
- # Dispatches multiple heterogeneous agent tasks in parallel using Ruby
88
- # threads. Each task is a Hash describing one agent invocation.
121
+ # Dispatches multiple heterogeneous agent tasks in parallel using
122
+ # cooperative {Task}s. Each task is a Hash describing one agent invocation.
89
123
  #
90
124
  # Results are returned in the same order as the input +tasks+ array.
91
125
  # Concurrency is bounded by +max_concurrency+; when nil all tasks run at
@@ -93,20 +127,35 @@ module Phronomy
93
127
  #
94
128
  # Error semantics are controlled by +on_error+:
95
129
  # - +:raise+ (default) — every task runs to completion; the first
96
- # exception in input order is then re-raised in the calling thread.
130
+ # exception in input order is then re-raised in the calling task.
97
131
  # - +:skip+ — failed tasks return +nil+; no exception is raised.
98
132
  #
99
133
  # @param tasks [Array<Hash>]
100
134
  # @option task [Class] :agent agent class to invoke (required)
101
135
  # @option task [String] :input input string for the agent (required)
102
136
  # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
103
- # @param max_concurrency [Integer, nil] maximum number of concurrent threads;
137
+ # @option task [String] :thread_id forwarded to +agent#invoke+ (default: nil)
138
+ # @param max_concurrency [Integer, nil] maximum number of concurrent tasks;
104
139
  # nil means no limit (all tasks run simultaneously)
105
- # @param on_error [Symbol] +:raise+ or +:skip+
140
+ # @param on_error [Symbol] +:raise+ or +:skip+
141
+ # @param timeout [Numeric, nil] maximum seconds to wait for all tasks;
142
+ # nil means wait indefinitely. When the deadline is exceeded,
143
+ # {Phronomy::TimeoutError} is raised and all surviving tasks are cancelled
144
+ # cooperatively.
145
+ # @param cancellation_token [Phronomy::CancellationToken, nil] when provided, the
146
+ # token is merged into each task's config (unless the task already sets one) so
147
+ # that every child agent checks it before making LLM calls.
148
+ # @param invocation_context [Phronomy::InvocationContext, nil] when provided,
149
+ # the context (cancellation_token, deadline, thread_id) is propagated to each
150
+ # child agent as a child InvocationContext.
151
+ # @param force_kill [Boolean] deprecated — cooperative cancellation is always
152
+ # used; this parameter is accepted for backwards compatibility but has no effect.
106
153
  # @return [Array<Hash, nil>] agent results in the same order as +tasks+
107
154
  # @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
108
155
  # @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
109
- def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise)
156
+ # @raise [Phronomy::TimeoutError] if +timeout+ is exceeded
157
+ # @api public
158
+ def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false)
110
159
  unless [:raise, :skip].include?(on_error)
111
160
  raise ArgumentError, "unknown on_error: #{on_error.inspect}"
112
161
  end
@@ -114,7 +163,7 @@ module Phronomy
114
163
  raise ArgumentError, "max_concurrency must be a positive Integer"
115
164
  end
116
165
 
117
- bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error)
166
+ bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error, timeout: timeout, cancellation_token: cancellation_token, invocation_context: invocation_context, force_kill: force_kill)
118
167
  end
119
168
 
120
169
  # Runs the same agent against multiple inputs in parallel (fan-out pattern).
@@ -125,70 +174,158 @@ module Phronomy
125
174
  # @param agent [Class] agent class to invoke for every input
126
175
  # @param inputs [Array<String>] list of input strings
127
176
  # @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}
177
+ # @param thread_id [String, nil] forwarded to every +agent#invoke+ call
178
+ # @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
179
+ # @param on_error [Symbol] forwarded to {#dispatch_parallel}
180
+ # @param invocation_context [Phronomy::InvocationContext, nil] forwarded to
181
+ # {#dispatch_parallel} for child context propagation
130
182
  # @return [Array<Hash, nil>] results in the same order as +inputs+
131
- def fan_out(agent:, inputs:, config: {}, max_concurrency: nil, on_error: :raise)
183
+ # @api public
184
+ def fan_out(agent:, inputs:, config: {}, thread_id: nil, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false)
132
185
  dispatch_parallel(
133
- *inputs.map { |input| {agent: agent, input: input, config: config} },
186
+ *inputs.map { |input| {agent: agent, input: input, config: config, thread_id: thread_id} },
134
187
  max_concurrency: max_concurrency,
135
- on_error: on_error
188
+ on_error: on_error,
189
+ timeout: timeout,
190
+ cancellation_token: cancellation_token,
191
+ invocation_context: invocation_context,
192
+ force_kill: force_kill
136
193
  )
137
194
  end
138
195
 
196
+ # Programmatically dispatches a single sub-agent from inside an orchestrator
197
+ # instance, inheriting the parent's +thread_id+ and +config+ by default.
198
+ #
199
+ # @param agent_class [Class] subclass of {Phronomy::Agent::Base}
200
+ # @param input [String] task or question for the sub-agent
201
+ # @param config [Hash, nil] override config (falls back to parent's)
202
+ # @param thread_id [String, nil] override thread_id (falls back to parent's)
203
+ # @return [Hash] the sub-agent's result hash (+:output+, +:messages+)
204
+ # @api public
205
+ def subagent(agent_class, input, config: nil, thread_id: nil)
206
+ ctx = @_orchestrator_context || {}
207
+ parent_ic = ctx[:invocation_context]
208
+ effective_config = config || ctx[:config] || {}
209
+
210
+ # Propagate parent InvocationContext to the child agent.
211
+ if parent_ic && !effective_config[:invocation_context]
212
+ child_ic = parent_ic.merge(parent_task_id: parent_ic.task_id)
213
+ effective_config = effective_config.merge(invocation_context: child_ic)
214
+ end
215
+
216
+ agent_class.new.invoke_async(
217
+ input,
218
+ config: effective_config,
219
+ thread_id: thread_id || ctx[:thread_id] || parent_ic&.thread_id
220
+ ).await
221
+ end
222
+
139
223
  private
140
224
 
141
- # Worker-pool implementation shared by {#dispatch_parallel} and {#fan_out}.
225
+ # Override invoke_once to expose the current thread_id and config via an
226
+ # instance variable so that DSL-registered subagent tools can inherit them
227
+ # without using Thread.current.
228
+ def invoke_once(input, messages: [], thread_id: nil, config: {})
229
+ prev = @_orchestrator_context
230
+ @_orchestrator_context = {
231
+ thread_id: thread_id,
232
+ config: config,
233
+ invocation_context: config[:invocation_context]
234
+ }
235
+ super
236
+ ensure
237
+ @_orchestrator_context = prev
238
+ end
239
+
240
+ # Override prepare_tool_class to inject the current orchestrator context
241
+ # into DSL-registered subagent tools before each call.
242
+ def prepare_tool_class(tool_class)
243
+ prepared = super
244
+ orch = self
245
+
246
+ # Only wrap subagent tools (those registered via the .subagent DSL).
247
+ return prepared unless self.class._subagent_tool_classes.include?(tool_class)
248
+
249
+ # Capture the effective tool name before building the anonymous subclass.
250
+ # Class-level instance variables (@tool_name) are not inherited through
251
+ # subclassing, so the wrapper must set it explicitly.
252
+ effective_name = prepared.new.name
253
+ Class.new(prepared) do
254
+ tool_name effective_name
255
+ define_method(:call) do |args|
256
+ self._orchestrator_context = orch.instance_variable_get(:@_orchestrator_context)
257
+ super(args)
258
+ end
259
+ end
260
+ end
261
+
262
+ # Task-based worker pool shared by {#dispatch_parallel} and {#fan_out}.
263
+ #
264
+ # Spawns one {Task} per input using a {TaskGroup} so that +max_concurrency+
265
+ # acts as a semaphore: spare tasks block on {TaskGroup#spawn} until a slot
266
+ # becomes available. Results are written back to +results+ in input order;
267
+ # +errors+ captures the first error per position so that the first error in
268
+ # *input* order is deterministically re-raised when +on_error: :raise+ is used.
142
269
  #
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.
270
+ # When +timeout+ is given, each spawned task is joined with the remaining
271
+ # deadline. Any still-alive tasks are cancelled cooperatively via
272
+ # {TaskGroup#cancel_all!} before {Phronomy::TimeoutError} is raised.
273
+ # The +force_kill+ argument is deprecated: cooperative cancellation is always
274
+ # used regardless of its value.
147
275
  #
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:)
276
+ # Deadline tracking uses +Process.clock_gettime(Process::CLOCK_MONOTONIC)+
277
+ # to avoid sensitivity to NTP adjustments and system-clock changes.
278
+ def bounded_map(tasks, max_concurrency:, on_error:, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false) # rubocop:disable Lint/UnusedMethodArgument
154
279
  return [] if tasks.empty?
155
280
 
156
281
  results = Array.new(tasks.length)
157
282
  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
283
+ group = Phronomy::Runtime.instance.task_group(limit: max_concurrency || tasks.length)
284
+
285
+ # Resolve the effective cancellation token: explicit argument wins;
286
+ # fall back to the one embedded in the InvocationContext if present.
287
+ effective_ct = cancellation_token || invocation_context&.cancellation_token
288
+
289
+ spawned = tasks.each_with_index.map do |task, i|
290
+ group.spawn do
291
+ task_config = task.fetch(:config, {})
292
+
293
+ # Merge the shared cancellation token unless the task already has one.
294
+ if effective_ct && !task_config[:cancellation_token]
295
+ task_config = task_config.merge(cancellation_token: effective_ct)
296
+ end
297
+
298
+ # Propagate parent InvocationContext to each child task so that
299
+ # cancellation, deadline, and tracing carry through automatically.
300
+ if invocation_context && !task_config[:invocation_context]
301
+ child_ic = invocation_context.merge(parent_task_id: invocation_context.task_id)
302
+ task_config = task_config.merge(invocation_context: child_ic)
187
303
  end
304
+
305
+ results[i] = task[:agent].new.invoke_async(
306
+ task[:input],
307
+ config: task_config,
308
+ thread_id: task[:thread_id] || invocation_context&.thread_id
309
+ ).await
310
+ rescue => e
311
+ errors[i] = e unless on_error == :skip
188
312
  end
189
313
  end
190
314
 
191
- workers.each(&:join)
315
+ if timeout
316
+ deadline = Phronomy::Deadline.in(timeout)
317
+ spawned.each { |t| t.join([deadline.remaining_seconds, 0].max) }
318
+
319
+ alive = spawned.select(&:alive?)
320
+ unless alive.empty?
321
+ group.cancel_all!
322
+ raise Phronomy::TimeoutError,
323
+ "dispatch_parallel timed out after #{timeout}s " \
324
+ "(#{alive.length} of #{spawned.length} tasks still running)"
325
+ end
326
+ else
327
+ spawned.each(&:await)
328
+ end
192
329
 
193
330
  first_error = errors.compact.first
194
331
  raise first_error if first_error
@@ -5,20 +5,39 @@ module Phronomy
5
5
  # RubyLLM::Chat subclass that executes multiple tool calls concurrently.
6
6
  #
7
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.).
8
+ # tool is dispatched according to its +execution_mode+:
9
+ # - +:cooperative+ tools run via +Runtime.instance.spawn+, delegating
10
+ # scheduling to the configured runtime backend.
11
+ # - +:blocking_io+ tools are offloaded to a +BlockingAdapterPool+ worker
12
+ # thread so they do not occupy a scheduler task slot.
13
+ # All results are collected before being appended to the message history,
14
+ # preserving deterministic message order while reducing wall-clock latency
15
+ # when tools are IO-bound (HTTP calls, DB queries, etc.).
12
16
  #
13
17
  # Single-tool responses fall through to the standard sequential path via
14
18
  # +super+, preserving all existing edge-case behaviour (Tool::Halt,
15
19
  # forced_tool_choice, streaming, SuspendSignal, etc.).
16
20
  #
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
+ # This class is used automatically when EventLoop mode is enabled
22
+ # ({Phronomy.configuration.event_loop}). It is not used for direct
23
+ # synchronous +invoke+ calls so that the streaming callback state remains
24
+ # single-threaded.
25
+ # @api private
21
26
  class ParallelToolChat < RubyLLM::Chat
27
+ # @param max_parallel_tools [Integer] maximum simultaneous tool executions
28
+ # @param cancellation_token [Phronomy::CancellationToken, nil] token observed before each batch
29
+ # @param opts [Hash] remaining kwargs forwarded to RubyLLM::Chat
30
+ # @api private
31
+ def initialize(max_parallel_tools: 10, cancellation_token: nil, **opts)
32
+ super(**opts)
33
+ @max_parallel_tools = max_parallel_tools
34
+ @cancellation_token = cancellation_token
35
+ end
36
+
37
+ # Allows the owning agent to update the token between retries.
38
+ # @api private
39
+ attr_writer :cancellation_token
40
+
22
41
  private
23
42
 
24
43
  # Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
@@ -28,12 +47,14 @@ module Phronomy
28
47
  # 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
29
48
  # sequential so that the Suspendable concern's approval hook can
30
49
  # raise +SuspendSignal+ before any tool is executed.
31
- # 2. Parallel tool execution — one IO thread per tool call.
50
+ # 2. Parallel tool execution — cooperative tools via Runtime.instance.spawn
51
+ # (respects the configured runtime backend), blocking_io tools via BlockingAdapterPool.
32
52
  # 3. Post-execution callbacks and message recording — sequential,
33
53
  # in the original tool-call order.
34
54
  #
35
55
  # @param response [RubyLLM::Message] the LLM response containing tool calls
36
56
  # @yield streaming block forwarded to +complete+
57
+ # @api private
37
58
  def handle_tool_calls(response, &block)
38
59
  tool_calls = response.tool_calls.values
39
60
 
@@ -50,14 +71,48 @@ module Phronomy
50
71
  end
51
72
 
52
73
  # 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)} }
74
+ # :cooperative tools run inside a Task (no pool).
75
+ # :blocking_io/:cpu_bound/:external_process tools are submitted directly
76
+ # to BlockingAdapterPool when available — eliminating the extra Task
77
+ # Thread that previously wrapped each pool operation.
78
+ #
79
+ # Both Phronomy::Task and BlockingAdapterPool::PendingOperation support
80
+ # #await, so results are collected uniformly below.
81
+ ct = @cancellation_token
82
+ max = @max_parallel_tools
83
+ tool_results = tool_calls.each_slice(max).flat_map do |batch|
84
+ if ct&.cancelled?
85
+ raise Phronomy::CancellationError, "invocation cancelled before tool execution"
86
+ end
87
+
88
+ # Dispatch all tools in this batch via ToolExecutor (centralised routing).
89
+ dispatched = batch.map do |tc|
90
+ tool = tools[tc.name.to_sym]
91
+ unless tool
92
+ next {tool_call: tc, awaitable: nil, result: {
93
+ error: "Model tried to call unavailable tool `#{tc.name}`. " \
94
+ "Available tools: #{tools.keys.to_json}."
95
+ }}
96
+ end
97
+
98
+ awaitable = Phronomy::ToolExecutor.call_async(
99
+ tool: tool,
100
+ args: tc.arguments,
101
+ cancellation_token: ct
102
+ )
103
+ {tool_call: tc, awaitable: awaitable, result: nil}
104
+ end
105
+
106
+ # Await all dispatched operations in original order.
107
+ dispatched.map do |item|
108
+ result = item[:awaitable] ? item[:awaitable].await : item[:result]
109
+ {tool_call: item[:tool_call], result: result}
110
+ end
55
111
  end
56
- results = thread_results.map(&:value)
57
112
 
58
113
  # Phase 3 — post-execution callbacks and message recording (sequential).
59
114
  halt_result = nil
60
- results.each do |item|
115
+ tool_results.each do |item|
61
116
  result = item[:result]
62
117
  @on[:tool_result]&.call(result)
63
118
  tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
@@ -70,6 +125,25 @@ module Phronomy
70
125
  reset_tool_choice if forced_tool_choice?
71
126
  halt_result || complete(&block)
72
127
  end
128
+
129
+ # Overrides RubyLLM::Chat#execute_tool to forward the cancellation token
130
+ # explicitly and to route the call through {ToolExecutor} so that the
131
+ # execution_mode decision is made in a single place.
132
+ def execute_tool(tool_call)
133
+ tool = tools[tool_call.name.to_sym]
134
+ unless tool
135
+ return {
136
+ error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
137
+ "Available tools: #{tools.keys.to_json}."
138
+ }
139
+ end
140
+
141
+ Phronomy::ToolExecutor.call_async(
142
+ tool: tool,
143
+ args: tool_call.arguments,
144
+ cancellation_token: @cancellation_token
145
+ ).await
146
+ end
73
147
  end
74
148
  end
75
149
  end
@@ -13,6 +13,10 @@ module Phronomy
13
13
  caller_meta = {}
14
14
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
15
15
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
16
+ if (ic = config[:invocation_context])
17
+ caller_meta[:task_id] = ic.task_id if ic.task_id
18
+ caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
19
+ end
16
20
 
17
21
  trace("agent.invoke", input: input, **caller_meta) do |_span|
18
22
  # Run input guardrails before any LLM interaction.
@@ -37,9 +41,10 @@ module Phronomy
37
41
  end
38
42
  end
39
43
 
40
- # Fall back to the last message
41
- # guards against the case where the final message is a tool-call or
42
- output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
44
+ # Select the last assistant-produced content as the output, skipping
45
+ # raw tool result messages (role: :tool) to avoid returning tool JSON
46
+ # or status strings as the agent's answer when iterations are exhausted.
47
+ output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
43
48
 
44
49
  # Run output guardrails before returning to the caller.
45
50
  run_output_guardrails!(output)
@@ -60,12 +65,17 @@ module Phronomy
60
65
  # @param config [Hash]
61
66
  # @yield [Phronomy::Agent::StreamEvent]
62
67
  # @return [Hash] { output:, messages:, usage: }
68
+ # @api public
63
69
  def stream(input, messages: [], thread_id: nil, config: {}, &block)
64
70
  return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
65
71
 
66
72
  caller_meta = {}
67
73
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
68
74
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
75
+ if (ic = config[:invocation_context])
76
+ caller_meta[:task_id] = ic.task_id if ic.task_id
77
+ caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
78
+ end
69
79
 
70
80
  trace("agent.invoke", input: input, **caller_meta) do |_span|
71
81
  run_input_guardrails!(input)
@@ -88,9 +98,9 @@ module Phronomy
88
98
  end
89
99
  end
90
100
 
91
- # Fall back to the last message that carries non-nil content (same as
92
- # the non-streaming path above).
93
- output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
101
+ # Select the last assistant-produced content as the output, skipping
102
+ # raw tool result messages (role: :tool) — same as the non-streaming path.
103
+ output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
94
104
  run_output_guardrails!(output)
95
105
 
96
106
  result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
@@ -30,6 +30,7 @@ module Phronomy
30
30
  # @param routes [Hash{Phronomy::Agent::Base => Array<Phronomy::Agent::Base>}]
31
31
  # declares which target agents each source agent may hand off to;
32
32
  # when omitted no handoffs are configured and the entry agent handles everything
33
+ # @api public
33
34
  def initialize(agents:, routes: {})
34
35
  @agents = Array(agents)
35
36
  raise ArgumentError, "At least one agent is required" if @agents.empty?
@@ -47,6 +48,7 @@ module Phronomy
47
48
  # @param config [Hash] forwarded to each agent's #invoke
48
49
  # @return [Hash] { output:, messages:, usage:, agent: }
49
50
  # @raise [Phronomy::HandoffError] if more than MAX_HANDOFFS handoffs occur
51
+ # @api public
50
52
  def invoke(input, config: {})
51
53
  current = @entry_agent
52
54
  handoffs_taken = 0
@@ -57,6 +57,7 @@ module Phronomy
57
57
 
58
58
  # Returns a shallow copy of all findings in insertion order.
59
59
  # @return [Array<Hash>]
60
+ # @api public
60
61
  def read_all
61
62
  @findings.dup
62
63
  end
@@ -66,6 +67,7 @@ module Phronomy
66
67
  # @param content [String] the finding text
67
68
  # @param cycle [Integer] the current cycle number
68
69
  # @return [nil]
70
+ # @api public
69
71
  def write(agent:, content:, cycle:)
70
72
  @findings << {agent: agent, content: content, cycle: cycle}
71
73
  nil
@@ -73,6 +75,7 @@ module Phronomy
73
75
 
74
76
  # Returns the number of findings recorded so far.
75
77
  # @return [Integer]
78
+ # @api public
76
79
  def size
77
80
  @findings.size
78
81
  end
@@ -85,6 +88,7 @@ module Phronomy
85
88
  # @param klass [Class] an Agent::Base subclass
86
89
  # @param instruction [String, nil] optional per-agent coordination instruction
87
90
  # appended to the team coordination text in this agent's prompt
91
+ # @api public
88
92
  def member(klass, instruction: nil)
89
93
  @members ||= []
90
94
  @members << {klass: klass, instruction: instruction}
@@ -94,6 +98,7 @@ module Phronomy
94
98
  # per-agent instruction. Prefer {.member} for new code.
95
99
  #
96
100
  # @param classes [Array<Class>] Agent::Base subclasses
101
+ # @api public
97
102
  def researchers(*classes)
98
103
  classes.flatten.each { |klass| member(klass) }
99
104
  end
@@ -104,6 +109,7 @@ module Phronomy
104
109
  # workflow. Override this when you need a different protocol or tone.
105
110
  #
106
111
  # @param text [String, nil] the coordination instructions
112
+ # @api public
107
113
  def coordination(text = nil)
108
114
  text ? @coordination = text : @coordination
109
115
  end
@@ -112,6 +118,7 @@ module Phronomy
112
118
  # At least one of +max_cycles+ or +timeout+ must be configured.
113
119
  #
114
120
  # @param value [Integer, nil]
121
+ # @api public
115
122
  def max_cycles(value = nil)
116
123
  value ? @max_cycles = Integer(value) : @max_cycles
117
124
  end
@@ -120,6 +127,7 @@ module Phronomy
120
127
  # At least one of +max_cycles+ or +timeout+ must be configured.
121
128
  #
122
129
  # @param value [Numeric, nil]
130
+ # @api public
123
131
  def timeout(value = nil)
124
132
  value ? @timeout = value.to_f : @timeout
125
133
  end
@@ -128,6 +136,7 @@ module Phronomy
128
136
  # cycle; when it returns +true+ the loop terminates early.
129
137
  #
130
138
  # @yield [KnowledgeStore] receives the store; return +true+ to stop
139
+ # @api public
131
140
  def terminate_when(&block)
132
141
  block ? @terminate_when = block : @terminate_when
133
142
  end
@@ -136,6 +145,7 @@ module Phronomy
136
145
  # When omitted, +store.read_all+ is used as-is.
137
146
  #
138
147
  # @yield [KnowledgeStore] receives the final store; return value becomes +:output+
148
+ # @api public
139
149
  def aggregate(&block)
140
150
  block ? @aggregator = block : @aggregator
141
151
  end
@@ -162,6 +172,7 @@ module Phronomy
162
172
  # @param config [Hash] reserved for future use
163
173
  # @return [Hash] +:output+, +:cycles+, +:terminated_by+
164
174
  # @raise [ArgumentError] when neither +max_cycles+ nor +timeout+ is configured
175
+ # @api public
165
176
  def invoke(input, config: {})
166
177
  validate_termination!
167
178
 
@@ -8,6 +8,7 @@ module Phronomy
8
8
  # suspended result hash containing a Checkpoint.
9
9
  #
10
10
  # This class is intentionally NOT part of the public API. Callers should
11
+ # @api private
11
12
  # inspect the +:suspended+ key in the result hash returned by #invoke.
12
13
  #
13
14
  # @api private
@@ -24,6 +25,7 @@ module Phronomy
24
25
  # @param tool_name [String]
25
26
  # @param args [Hash]
26
27
  # @param tool_call_id [String]
28
+ # @api private
27
29
  def initialize(tool_name:, args:, tool_call_id:)
28
30
  super("Agent suspended waiting for approval of tool: #{tool_name}")
29
31
  @tool_name = tool_name