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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- 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
|
|
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::
|
|
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::
|
|
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
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
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
|
|
97
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
119
|
-
#
|
|
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
|
|
122
|
-
# @param
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
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
|
|
154
|
-
# @param on_error
|
|
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 =
|
|
179
|
-
|
|
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:
|
|
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
|
|
189
|
-
#
|
|
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 =
|
|
192
|
-
|
|
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
|
-
|
|
237
|
+
@_orchestrator_context = prev
|
|
196
238
|
end
|
|
197
239
|
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
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,
|
|
212
|
-
#
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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 =
|
|
277
|
-
|
|
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 =
|
|
319
|
+
alive = spawned.select(&:alive?)
|
|
283
320
|
unless alive.empty?
|
|
284
|
-
|
|
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 #{
|
|
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
|
|
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::
|
|
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.
|