phronomy 0.7.0 → 0.8.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Agent
4
+ module MultiAgent
5
5
  # Base class for orchestrator agents that coordinate multiple subagents.
6
6
  # Implements the Orchestrator-Subagent multi-agent coordination pattern
7
7
  # (Anthropic blog, Pattern 2).
@@ -16,7 +16,7 @@ module Phronomy
16
16
  # - +fan_out+ for parallel invocation of the same agent across multiple inputs.
17
17
  #
18
18
  # @example Declarative DSL
19
- # class ResearchOrchestrator < Phronomy::Agent::Orchestrator
19
+ # class ResearchOrchestrator < Phronomy::MultiAgent::Orchestrator
20
20
  # model "gpt-4o"
21
21
  # instructions "You coordinate research tasks."
22
22
  # subagent :searcher, SearchAgent
@@ -26,7 +26,7 @@ module Phronomy
26
26
  # result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
27
27
  #
28
28
  # @example Programmatic parallel dispatch
29
- # class MyOrchestrator < Phronomy::Agent::Orchestrator
29
+ # class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
30
30
  # model "gpt-4o"
31
31
  # instructions "Dispatch tasks in parallel."
32
32
  #
@@ -41,7 +41,7 @@ module Phronomy
41
41
  #
42
42
  # @example Fan-out (same agent, multiple inputs)
43
43
  # results = fan_out(agent: TranslationAgent, inputs: ["Hello", "World"])
44
- class Orchestrator < Base
44
+ class Orchestrator < Agent::Base
45
45
  # Declares a named subagent and registers it as a tool accessible to the
46
46
  # LLM during an +invoke+ call.
47
47
  #
@@ -62,15 +62,29 @@ module Phronomy
62
62
  description "Dispatch work to the #{name} subagent (#{agent_class.name})"
63
63
  param :input, type: :string, desc: "The task or question for the subagent"
64
64
 
65
+ # @_orchestrator_context is injected at call time by prepare_tool_class.
66
+ attr_writer :_orchestrator_context
67
+
65
68
  define_method(:execute) do |input:|
66
- # Inherit the calling orchestrator's thread_id and config when
67
- # available so that sub-agent spans and memory stay connected.
68
- ctx = Thread.current[:phronomy_orchestrator_context] || {}
69
- result = agent_class.new.invoke(
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(
70
84
  input,
71
- thread_id: ctx[:thread_id],
72
- config: ctx[:config] || {}
73
- )
85
+ thread_id: ctx[:thread_id] || parent_ic&.thread_id,
86
+ config: task_config
87
+ ).await
74
88
  result[:output]
75
89
  rescue
76
90
  raise if on_error == :raise
@@ -78,6 +92,9 @@ module Phronomy
78
92
  end
79
93
  end
80
94
 
95
+ # Track this tool class so prepare_tool_class can inject context.
96
+ @_subagent_tool_classes = (@_subagent_tool_classes || []) + [tool_class]
97
+
81
98
  # Append without clobbering previously registered tools or aliases.
82
99
  @tools = (@tools || []) + [tool_class]
83
100
  @tool_aliases ||= {}
@@ -85,6 +102,14 @@ module Phronomy
85
102
  registered_subagents[name] = {agent_class: agent_class, on_error: on_error}
86
103
  end
87
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
+
88
113
  # Returns the subagent registry for this specific class (not inherited).
89
114
  #
90
115
  # @return [Hash{Symbol => Hash}]
@@ -93,8 +118,8 @@ module Phronomy
93
118
  @registered_subagents ||= {}
94
119
  end
95
120
 
96
- # Dispatches multiple heterogeneous agent tasks in parallel using Ruby
97
- # 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.
98
123
  #
99
124
  # Results are returned in the same order as the input +tasks+ array.
100
125
  # Concurrency is bounded by +max_concurrency+; when nil all tasks run at
@@ -102,7 +127,7 @@ module Phronomy
102
127
  #
103
128
  # Error semantics are controlled by +on_error+:
104
129
  # - +:raise+ (default) — every task runs to completion; the first
105
- # 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.
106
131
  # - +:skip+ — failed tasks return +nil+; no exception is raised.
107
132
  #
108
133
  # @param tasks [Array<Hash>]
@@ -110,27 +135,27 @@ module Phronomy
110
135
  # @option task [String] :input input string for the agent (required)
111
136
  # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
112
137
  # @option task [String] :thread_id forwarded to +agent#invoke+ (default: nil)
113
- # @param max_concurrency [Integer, nil] maximum number of concurrent threads;
138
+ # @param max_concurrency [Integer, nil] maximum number of concurrent tasks;
114
139
  # nil means no limit (all tasks run simultaneously)
115
140
  # @param on_error [Symbol] +:raise+ or +:skip+
116
- # @param timeout [Numeric, nil] maximum seconds to wait for all workers;
141
+ # @param timeout [Numeric, nil] maximum seconds to wait for all tasks;
117
142
  # nil means wait indefinitely. When the deadline is exceeded,
118
- # {Phronomy::TimeoutError} is raised and all surviving worker threads are killed.
119
- # @param cancellation_token [Phronomy::CancellationToken, nil] when provided, the
143
+ # {Phronomy::TimeoutError} is raised and all surviving tasks are cancelled
144
+ # cooperatively.
145
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] when provided, the
120
146
  # token is merged into each task's config (unless the task already sets one) so
121
- # that every worker agent checks it before making LLM calls.
122
- # @param force_kill [Boolean] when +true+, surviving worker threads are killed with
123
- # +Thread#kill+ after the grace period if they do not stop cooperatively. When
124
- # +false+ (default), workers are asked to stop cooperatively but are never killed;
125
- # the caller receives {Phronomy::TimeoutError} immediately and abandoned workers
126
- # discard their results when they eventually finish. +false+ is safer for
127
- # production because +Thread#kill+ can interrupt +ensure+ blocks.
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.
128
153
  # @return [Array<Hash, nil>] agent results in the same order as +tasks+
129
154
  # @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
130
155
  # @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
131
156
  # @raise [Phronomy::TimeoutError] if +timeout+ is exceeded
132
157
  # @api public
133
- def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, force_kill: false)
158
+ def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false)
134
159
  unless [:raise, :skip].include?(on_error)
135
160
  raise ArgumentError, "unknown on_error: #{on_error.inspect}"
136
161
  end
@@ -138,7 +163,7 @@ module Phronomy
138
163
  raise ArgumentError, "max_concurrency must be a positive Integer"
139
164
  end
140
165
 
141
- bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error, timeout: timeout, cancellation_token: cancellation_token, force_kill: force_kill)
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)
142
167
  end
143
168
 
144
169
  # Runs the same agent against multiple inputs in parallel (fan-out pattern).
@@ -150,17 +175,20 @@ module Phronomy
150
175
  # @param inputs [Array<String>] list of input strings
151
176
  # @param config [Hash] forwarded to every +agent#invoke+ call
152
177
  # @param thread_id [String, nil] forwarded to every +agent#invoke+ call
153
- # @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
154
- # @param on_error [Symbol] forwarded to {#dispatch_parallel}
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
155
182
  # @return [Array<Hash, nil>] results in the same order as +inputs+
156
183
  # @api public
157
- def fan_out(agent:, inputs:, config: {}, thread_id: nil, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, force_kill: false)
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)
158
185
  dispatch_parallel(
159
186
  *inputs.map { |input| {agent: agent, input: input, config: config, thread_id: thread_id} },
160
187
  max_concurrency: max_concurrency,
161
188
  on_error: on_error,
162
189
  timeout: timeout,
163
190
  cancellation_token: cancellation_token,
191
+ invocation_context: invocation_context,
164
192
  force_kill: force_kill
165
193
  )
166
194
  end
@@ -175,131 +203,128 @@ module Phronomy
175
203
  # @return [Hash] the sub-agent's result hash (+:output+, +:messages+)
176
204
  # @api public
177
205
  def subagent(agent_class, input, config: nil, thread_id: nil)
178
- ctx = Thread.current[:phronomy_orchestrator_context] || {}
179
- agent_class.new.invoke(
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(
180
217
  input,
181
- config: config || ctx[:config] || {},
182
- thread_id: thread_id || ctx[:thread_id]
183
- )
218
+ config: effective_config,
219
+ thread_id: thread_id || ctx[:thread_id] || parent_ic&.thread_id
220
+ ).await
184
221
  end
185
222
 
186
223
  private
187
224
 
188
- # Override invoke_once to expose the current thread_id and config via a
189
- # thread-local so that DSL-registered subagent tools can inherit them.
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.
190
228
  def invoke_once(input, messages: [], thread_id: nil, config: {})
191
- prev = Thread.current[:phronomy_orchestrator_context]
192
- Thread.current[:phronomy_orchestrator_context] = {thread_id: thread_id, config: config}
229
+ prev = @_orchestrator_context
230
+ @_orchestrator_context = {
231
+ thread_id: thread_id,
232
+ config: config,
233
+ invocation_context: config[:invocation_context]
234
+ }
193
235
  super
194
236
  ensure
195
- Thread.current[:phronomy_orchestrator_context] = prev
237
+ @_orchestrator_context = prev
196
238
  end
197
239
 
198
- # Worker-pool implementation shared by {#dispatch_parallel} and {#fan_out}.
199
- #
200
- # Uses a +Queue+ as a work-stealing mechanism: each worker thread pops a
201
- # task, executes it, and loops until the queue is empty. The number of
202
- # workers is +min(max_concurrency, tasks.length)+, capped at the task count
203
- # so we never spin up idle threads.
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}.
204
263
  #
205
- # +errors+ is indexed by task position so that the first error in *input*
206
- # order is deterministically re-raised when +on_error: :raise+ is used.
207
- # A +Mutex+ guards concurrent writes to +errors+ even though Array element
208
- # assignment at different indices is safe in MRI; this keeps the code
209
- # correct across alternative Ruby runtimes.
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.
210
269
  #
211
- # When +timeout+ is given, workers are first asked to stop cooperatively
212
- # via a cancellation flag (so they do not pick up new tasks) and then given
213
- # +KILL_GRACE_SECONDS+ to finish any in-flight +ensure+ blocks. Only
214
- # workers that are still alive after the grace period are force-killed, and
215
- # a warning is logged in that case. Use a +CancellationToken+ (see #216)
216
- # for full cooperative cancellation of long-running tasks.
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.
217
275
  #
218
276
  # Deadline tracking uses +Process.clock_gettime(Process::CLOCK_MONOTONIC)+
219
277
  # to avoid sensitivity to NTP adjustments and system-clock changes.
220
- KILL_GRACE_SECONDS = 0.5
221
- private_constant :KILL_GRACE_SECONDS
222
-
223
- def bounded_map(tasks, max_concurrency:, on_error:, timeout: nil, cancellation_token: nil, force_kill: false)
278
+ def bounded_map(tasks, max_concurrency:, on_error:, timeout: nil, cancellation_token: nil, invocation_context: nil, force_kill: false) # rubocop:disable Lint/UnusedMethodArgument
224
279
  return [] if tasks.empty?
225
280
 
226
281
  results = Array.new(tasks.length)
227
282
  errors = Array.new(tasks.length)
228
- errors_mutex = Mutex.new
229
- # Mutex-backed cooperative stop token; workers check before each task pick-up.
230
- internal_stop_token = Phronomy::CancellationToken.new
231
-
232
- queue = Queue.new
233
- tasks.each_with_index { |task, i| queue << [i, task] }
234
-
235
- worker_count = [max_concurrency || tasks.length, tasks.length].min
236
-
237
- workers = worker_count.times.map do
238
- Thread.new do
239
- loop do
240
- break if internal_stop_token.cancelled?
241
-
242
- i, task = begin
243
- queue.pop(true)
244
- rescue ThreadError
245
- break # queue is empty; this worker is done
246
- end
247
-
248
- # Merge the shared cancellation token into the task's config unless
249
- # the task already supplies its own token.
250
- task_config = task.fetch(:config, {})
251
- if cancellation_token && !task_config[:cancellation_token]
252
- task_config = task_config.merge(cancellation_token: cancellation_token)
253
- end
254
-
255
- begin
256
- results[i] = task[:agent].new.invoke(
257
- task[:input],
258
- config: task_config,
259
- thread_id: task[:thread_id]
260
- )
261
- rescue => e
262
- case on_error
263
- when :skip
264
- results[i] = nil
265
- else
266
- errors_mutex.synchronize { errors[i] = e }
267
- end
268
- 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)
269
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
270
312
  end
271
313
  end
272
314
 
273
- workers.each(&:join) if timeout.nil?
274
-
275
315
  if timeout
276
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
277
- workers.each do |w|
278
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
279
- w.join([remaining, 0].max)
280
- end
316
+ deadline = Phronomy::Concurrency::Deadline.in(timeout)
317
+ spawned.each { |t| t.join([deadline.remaining_seconds, 0].max) }
281
318
 
282
- alive = workers.select(&:alive?)
319
+ alive = spawned.select(&:alive?)
283
320
  unless alive.empty?
284
- # Signal workers cooperatively to stop picking up new tasks.
285
- internal_stop_token.cancel!
286
- if force_kill
287
- # Give in-flight ensure blocks a short grace period before kill.
288
- alive.each { |w| w.join(KILL_GRACE_SECONDS) }
289
- still_alive = alive.select(&:alive?)
290
- if still_alive.any?
291
- Phronomy.configuration.logger&.warn(
292
- "[Phronomy] dispatch_parallel: #{still_alive.length} worker(s) did not stop " \
293
- "within grace period; force-killing. Use CancellationToken for " \
294
- "cooperative cancellation of long-running tasks."
295
- )
296
- still_alive.each(&:kill)
297
- end
298
- end
321
+ group.cancel_all!
299
322
  raise Phronomy::TimeoutError,
300
323
  "dispatch_parallel timed out after #{timeout}s " \
301
- "(#{alive.length} of #{workers.length} workers still running)"
324
+ "(#{alive.length} of #{spawned.length} tasks still running)"
302
325
  end
326
+ else
327
+ spawned.each(&:await)
303
328
  end
304
329
 
305
330
  first_error = errors.compact.first
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
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 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.).
16
+ #
17
+ # Single-tool responses fall through to the standard sequential path via
18
+ # +super+, preserving all existing edge-case behaviour (Tool::Halt,
19
+ # forced_tool_choice, streaming, SuspendSignal, etc.).
20
+ #
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
26
+ class ParallelToolChat < RubyLLM::Chat
27
+ # @param max_parallel_tools [Integer] maximum simultaneous tool executions
28
+ # @param cancellation_token [Phronomy::Concurrency::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
+
41
+ private
42
+
43
+ # Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
44
+ # when multiple tool calls are present in a single LLM response.
45
+ #
46
+ # The method preserves the three-phase protocol of the original:
47
+ # 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
48
+ # sequential so that the Suspendable concern's approval hook can
49
+ # raise +SuspendSignal+ before any tool is executed.
50
+ # 2. Parallel tool execution — cooperative tools via Runtime.instance.spawn
51
+ # (respects the configured runtime backend), blocking_io tools via BlockingAdapterPool.
52
+ # 3. Post-execution callbacks and message recording — sequential,
53
+ # in the original tool-call order.
54
+ #
55
+ # @param response [RubyLLM::Message] the LLM response containing tool calls
56
+ # @yield streaming block forwarded to +complete+
57
+ # @api private
58
+ def handle_tool_calls(response, &block)
59
+ tool_calls = response.tool_calls.values
60
+
61
+ # Single tool: delegate to the parent implementation to preserve every
62
+ # edge case (forced_tool_choice, streaming, Halt, SuspendSignal…).
63
+ return super if tool_calls.size <= 1
64
+
65
+ # Phase 1 — pre-execution callbacks (sequential, original order).
66
+ # The SuspendSignal approval hook is registered via on_tool_call, so it
67
+ # MUST fire before execution begins.
68
+ tool_calls.each do |tool_call|
69
+ @on[:new_message]&.call
70
+ @on[:tool_call]&.call(tool_call)
71
+ end
72
+
73
+ # Phase 2 — parallel tool execution.
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::Agent::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
111
+ end
112
+
113
+ # Phase 3 — post-execution callbacks and message recording (sequential).
114
+ halt_result = nil
115
+ tool_results.each do |item|
116
+ result = item[:result]
117
+ @on[:tool_result]&.call(result)
118
+ tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
119
+ content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
120
+ message = add_message(role: :tool, content: content, tool_call_id: item[:tool_call].id)
121
+ @on[:end_message]&.call(message)
122
+ halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
123
+ end
124
+
125
+ reset_tool_choice if forced_tool_choice?
126
+ halt_result || complete(&block)
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::Agent::ToolExecutor.call_async(
142
+ tool: tool,
143
+ args: tool_call.arguments,
144
+ cancellation_token: @cancellation_token
145
+ ).await
146
+ end
147
+ end
148
+ end
149
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Agent
4
+ module MultiAgent
5
5
  # Implements the "Agent teams" coordination pattern (Anthropic blog, Pattern 3).
6
6
  #
7
7
  # @see https://claude.com/blog/multi-agent-coordination-patterns
@@ -24,7 +24,7 @@ module Phronomy
24
24
  # +invoke+ call, so the LLM retains context across multiple task assignments.
25
25
  #
26
26
  # @example Basic usage
27
- # class MigrationTeam < Phronomy::Agent::TeamCoordinator
27
+ # class MigrationTeam < Phronomy::MultiAgent::TeamCoordinator
28
28
  # coordinator_model "claude-3-5-sonnet-20241022"
29
29
  # coordinator_instructions <<~INST
30
30
  # Analyze the request and enqueue one migration task per service.