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.
- checksums.yaml +4 -4
- data/.mutant.yml +22 -0
- data/CHANGELOG.md +488 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +374 -36
- data/RELEASE_CHECKLIST.md +86 -0
- data/Rakefile +33 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +172 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +66 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +416 -49
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
- data/lib/phronomy/agent/fsm.rb +44 -52
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +191 -54
- data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
- data/lib/phronomy/agent/react_agent.rb +16 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +133 -0
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +168 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +22 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +11 -9
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +275 -30
- data/lib/phronomy/fsm_session.rb +57 -4
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +24 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -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 +374 -0
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- 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/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +298 -28
- data/lib/phronomy/tool/mcp_tool.rb +103 -17
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +40 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +147 -11
- data/lib/phronomy/workflow_context.rb +83 -6
- data/lib/phronomy/workflow_runner.rb +106 -7
- data/lib/phronomy.rb +112 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- 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
|
-
|
|
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
|
|
88
|
-
#
|
|
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
|
|
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
|
-
# @
|
|
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
|
|
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
|
-
|
|
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
|
|
129
|
-
# @param
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
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
|
-
#
|
|
149
|
-
#
|
|
150
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# are
|
|
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
|
|
18
|
-
# {
|
|
19
|
-
#
|
|
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
|
|
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 —
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
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
|
-
#
|
|
92
|
-
# the non-streaming path
|
|
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
|