phronomy 0.6.0 → 0.7.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 +21 -0
- data/CHANGELOG.md +338 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +242 -27
- data/RELEASE_CHECKLIST.md +86 -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 +171 -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 +51 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +48 -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/lib/phronomy/agent/base.rb +194 -12
- 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 +4 -0
- data/lib/phronomy/agent/fsm.rb +15 -0
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +123 -11
- data/lib/phronomy/agent/parallel_tool_chat.rb +21 -4
- data/lib/phronomy/agent/react_agent.rb +8 -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/cancellation_token.rb +92 -0
- data/lib/phronomy/configuration.rb +26 -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/embeddings/base.rb +5 -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 +2 -0
- 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 +114 -7
- data/lib/phronomy/fsm_session.rb +8 -1
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/knowledge_source/base.rb +6 -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/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/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/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/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +189 -27
- data/lib/phronomy/tool/mcp_tool.rb +68 -13
- 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 +2 -0
- data/lib/phronomy/vector_store/base.rb +33 -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 +96 -7
- data/lib/phronomy/workflow_context.rb +54 -4
- data/lib/phronomy/workflow_runner.rb +35 -7
- data/lib/phronomy.rb +70 -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 +45 -2
|
@@ -8,6 +8,7 @@ module Phronomy
|
|
|
8
8
|
# Included in {Phronomy::Agent::Base}. When a tool decorated with
|
|
9
9
|
# +requires_approval true+ is called and no synchronous approval handler
|
|
10
10
|
# has been registered, the invocation is suspended and a
|
|
11
|
+
# @api private
|
|
11
12
|
# {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
|
|
12
13
|
module Suspendable
|
|
13
14
|
# Registers a callback that is invoked before executing any tool that has
|
|
@@ -25,6 +26,7 @@ module Phronomy
|
|
|
25
26
|
# agent = MyAgent.new
|
|
26
27
|
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
27
28
|
# @return [self]
|
|
29
|
+
# @api private
|
|
28
30
|
def on_approval_required(&block)
|
|
29
31
|
@approval_handler = block
|
|
30
32
|
self
|
|
@@ -43,6 +45,7 @@ module Phronomy
|
|
|
43
45
|
# @param config [Hash] same runtime options as #invoke
|
|
44
46
|
# @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
|
|
45
47
|
# @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
|
|
48
|
+
# @api private
|
|
46
49
|
def resume(checkpoint, approved:, config: {})
|
|
47
50
|
# Build a fresh chat with all tools registered.
|
|
48
51
|
chat = build_chat
|
|
@@ -95,6 +98,7 @@ module Phronomy
|
|
|
95
98
|
# - none of the agent's tools have requires_approval set.
|
|
96
99
|
#
|
|
97
100
|
# @param chat [RubyLLM::Chat]
|
|
101
|
+
# @api private
|
|
98
102
|
def _register_suspension_hook!(chat)
|
|
99
103
|
return if @approval_handler
|
|
100
104
|
return if self.class.tools.none? { |tc| tc.requires_approval }
|
data/lib/phronomy/agent/fsm.rb
CHANGED
|
@@ -57,6 +57,7 @@ module Phronomy
|
|
|
57
57
|
# {Agent::Base#run_as_child} creates an +AgentFSM+ with +parent_id+ set to
|
|
58
58
|
# +ctx.thread_id+, registers it with the EventLoop, and returns immediately.
|
|
59
59
|
# The parent {FSMSession} waits for the +:child_completed+ event.
|
|
60
|
+
# @api private
|
|
60
61
|
class FSM
|
|
61
62
|
# @return [String] unique identifier used as the EventLoop target_id
|
|
62
63
|
attr_reader :id
|
|
@@ -87,6 +88,7 @@ module Phronomy
|
|
|
87
88
|
# entry :run_agent, ->(ctx) {
|
|
88
89
|
# MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
|
|
89
90
|
# }
|
|
91
|
+
# @api private
|
|
90
92
|
def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil, result_writer: nil)
|
|
91
93
|
@agent = agent
|
|
92
94
|
@input = input
|
|
@@ -131,6 +133,9 @@ module Phronomy
|
|
|
131
133
|
Thread.new do
|
|
132
134
|
# Enable parallel tool dispatch inside this IO thread.
|
|
133
135
|
Thread.current[:phronomy_agent_parallel_tools] = true
|
|
136
|
+
# Forward the concurrency cap to ParallelToolChat.
|
|
137
|
+
Thread.current[:phronomy_max_parallel_tools] =
|
|
138
|
+
agent.class.respond_to?(:max_parallel_tools) ? agent.class.max_parallel_tools : 10
|
|
134
139
|
|
|
135
140
|
begin
|
|
136
141
|
result = agent.send(:_invoke_impl,
|
|
@@ -154,9 +159,19 @@ module Phronomy
|
|
|
154
159
|
Phronomy::Event.new(type: :finished, target_id: fsm_id, payload: result)
|
|
155
160
|
)
|
|
156
161
|
rescue => e
|
|
162
|
+
if parent_id
|
|
163
|
+
Phronomy::EventLoop.instance.post(
|
|
164
|
+
Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
157
168
|
Phronomy::EventLoop.instance.post(
|
|
158
169
|
Phronomy::Event.new(type: :error, target_id: fsm_id, payload: e)
|
|
159
170
|
)
|
|
171
|
+
ensure
|
|
172
|
+
# Clear the thread-local context cache for this agent so the IO
|
|
173
|
+
# thread's cache does not grow unboundedly across invocations.
|
|
174
|
+
Thread.current[:phronomy_context_version_caches]&.delete(agent.object_id)
|
|
160
175
|
end
|
|
161
176
|
end
|
|
162
177
|
end
|
|
@@ -22,6 +22,7 @@ module Phronomy
|
|
|
22
22
|
|
|
23
23
|
# @param target_agent [Phronomy::Agent::Base] the agent to hand off to
|
|
24
24
|
# @param description [String, nil] overrides the auto-generated tool description
|
|
25
|
+
# @api public
|
|
25
26
|
def initialize(target_agent:, description: nil)
|
|
26
27
|
@target_agent = target_agent
|
|
27
28
|
klass_name = target_agent.class.name&.split("::")&.last || "Agent"
|
|
@@ -33,6 +34,7 @@ module Phronomy
|
|
|
33
34
|
|
|
34
35
|
# Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
|
|
35
36
|
# @return [Class<Phronomy::Tool::Base>]
|
|
37
|
+
# @api public
|
|
36
38
|
def to_tool_class
|
|
37
39
|
sentinel_value = sentinel
|
|
38
40
|
tn = tool_name
|
|
@@ -46,6 +48,7 @@ module Phronomy
|
|
|
46
48
|
|
|
47
49
|
# The sentinel string embedded in the tool result.
|
|
48
50
|
# @return [String]
|
|
51
|
+
# @api public
|
|
49
52
|
def sentinel
|
|
50
53
|
"#{SENTINEL_PREFIX}:#{target_agent.class.name}:#{@uuid}"
|
|
51
54
|
end
|
|
@@ -55,6 +55,7 @@ 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}"
|
|
@@ -62,7 +63,14 @@ module Phronomy
|
|
|
62
63
|
param :input, type: :string, desc: "The task or question for the subagent"
|
|
63
64
|
|
|
64
65
|
define_method(:execute) do |input:|
|
|
65
|
-
|
|
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(
|
|
70
|
+
input,
|
|
71
|
+
thread_id: ctx[:thread_id],
|
|
72
|
+
config: ctx[:config] || {}
|
|
73
|
+
)
|
|
66
74
|
result[:output]
|
|
67
75
|
rescue
|
|
68
76
|
raise if on_error == :raise
|
|
@@ -80,6 +88,7 @@ module Phronomy
|
|
|
80
88
|
# Returns the subagent registry for this specific class (not inherited).
|
|
81
89
|
#
|
|
82
90
|
# @return [Hash{Symbol => Hash}]
|
|
91
|
+
# @api public
|
|
83
92
|
def self.registered_subagents
|
|
84
93
|
@registered_subagents ||= {}
|
|
85
94
|
end
|
|
@@ -100,13 +109,28 @@ module Phronomy
|
|
|
100
109
|
# @option task [Class] :agent agent class to invoke (required)
|
|
101
110
|
# @option task [String] :input input string for the agent (required)
|
|
102
111
|
# @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
|
|
103
|
-
# @
|
|
112
|
+
# @option task [String] :thread_id forwarded to +agent#invoke+ (default: nil)
|
|
113
|
+
# @param max_concurrency [Integer, nil] maximum number of concurrent threads;
|
|
104
114
|
# nil means no limit (all tasks run simultaneously)
|
|
105
|
-
# @param on_error
|
|
115
|
+
# @param on_error [Symbol] +:raise+ or +:skip+
|
|
116
|
+
# @param timeout [Numeric, nil] maximum seconds to wait for all workers;
|
|
117
|
+
# 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
|
|
120
|
+
# 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.
|
|
106
128
|
# @return [Array<Hash, nil>] agent results in the same order as +tasks+
|
|
107
129
|
# @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
|
|
108
130
|
# @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
|
|
109
|
-
|
|
131
|
+
# @raise [Phronomy::TimeoutError] if +timeout+ is exceeded
|
|
132
|
+
# @api public
|
|
133
|
+
def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, force_kill: false)
|
|
110
134
|
unless [:raise, :skip].include?(on_error)
|
|
111
135
|
raise ArgumentError, "unknown on_error: #{on_error.inspect}"
|
|
112
136
|
end
|
|
@@ -114,7 +138,7 @@ module Phronomy
|
|
|
114
138
|
raise ArgumentError, "max_concurrency must be a positive Integer"
|
|
115
139
|
end
|
|
116
140
|
|
|
117
|
-
bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error)
|
|
141
|
+
bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error, timeout: timeout, cancellation_token: cancellation_token, force_kill: force_kill)
|
|
118
142
|
end
|
|
119
143
|
|
|
120
144
|
# Runs the same agent against multiple inputs in parallel (fan-out pattern).
|
|
@@ -125,19 +149,52 @@ module Phronomy
|
|
|
125
149
|
# @param agent [Class] agent class to invoke for every input
|
|
126
150
|
# @param inputs [Array<String>] list of input strings
|
|
127
151
|
# @param config [Hash] forwarded to every +agent#invoke+ call
|
|
152
|
+
# @param thread_id [String, nil] forwarded to every +agent#invoke+ call
|
|
128
153
|
# @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
|
|
129
154
|
# @param on_error [Symbol] forwarded to {#dispatch_parallel}
|
|
130
155
|
# @return [Array<Hash, nil>] results in the same order as +inputs+
|
|
131
|
-
|
|
156
|
+
# @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)
|
|
132
158
|
dispatch_parallel(
|
|
133
|
-
*inputs.map { |input| {agent: agent, input: input, config: config} },
|
|
159
|
+
*inputs.map { |input| {agent: agent, input: input, config: config, thread_id: thread_id} },
|
|
134
160
|
max_concurrency: max_concurrency,
|
|
135
|
-
on_error: on_error
|
|
161
|
+
on_error: on_error,
|
|
162
|
+
timeout: timeout,
|
|
163
|
+
cancellation_token: cancellation_token,
|
|
164
|
+
force_kill: force_kill
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Programmatically dispatches a single sub-agent from inside an orchestrator
|
|
169
|
+
# instance, inheriting the parent's +thread_id+ and +config+ by default.
|
|
170
|
+
#
|
|
171
|
+
# @param agent_class [Class] subclass of {Phronomy::Agent::Base}
|
|
172
|
+
# @param input [String] task or question for the sub-agent
|
|
173
|
+
# @param config [Hash, nil] override config (falls back to parent's)
|
|
174
|
+
# @param thread_id [String, nil] override thread_id (falls back to parent's)
|
|
175
|
+
# @return [Hash] the sub-agent's result hash (+:output+, +:messages+)
|
|
176
|
+
# @api public
|
|
177
|
+
def subagent(agent_class, input, config: nil, thread_id: nil)
|
|
178
|
+
ctx = Thread.current[:phronomy_orchestrator_context] || {}
|
|
179
|
+
agent_class.new.invoke(
|
|
180
|
+
input,
|
|
181
|
+
config: config || ctx[:config] || {},
|
|
182
|
+
thread_id: thread_id || ctx[:thread_id]
|
|
136
183
|
)
|
|
137
184
|
end
|
|
138
185
|
|
|
139
186
|
private
|
|
140
187
|
|
|
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.
|
|
190
|
+
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}
|
|
193
|
+
super
|
|
194
|
+
ensure
|
|
195
|
+
Thread.current[:phronomy_orchestrator_context] = prev
|
|
196
|
+
end
|
|
197
|
+
|
|
141
198
|
# Worker-pool implementation shared by {#dispatch_parallel} and {#fan_out}.
|
|
142
199
|
#
|
|
143
200
|
# Uses a +Queue+ as a work-stealing mechanism: each worker thread pops a
|
|
@@ -150,12 +207,27 @@ module Phronomy
|
|
|
150
207
|
# A +Mutex+ guards concurrent writes to +errors+ even though Array element
|
|
151
208
|
# assignment at different indices is safe in MRI; this keeps the code
|
|
152
209
|
# correct across alternative Ruby runtimes.
|
|
153
|
-
|
|
210
|
+
#
|
|
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.
|
|
217
|
+
#
|
|
218
|
+
# Deadline tracking uses +Process.clock_gettime(Process::CLOCK_MONOTONIC)+
|
|
219
|
+
# 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)
|
|
154
224
|
return [] if tasks.empty?
|
|
155
225
|
|
|
156
226
|
results = Array.new(tasks.length)
|
|
157
227
|
errors = Array.new(tasks.length)
|
|
158
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
|
|
159
231
|
|
|
160
232
|
queue = Queue.new
|
|
161
233
|
tasks.each_with_index { |task, i| queue << [i, task] }
|
|
@@ -165,16 +237,26 @@ module Phronomy
|
|
|
165
237
|
workers = worker_count.times.map do
|
|
166
238
|
Thread.new do
|
|
167
239
|
loop do
|
|
240
|
+
break if internal_stop_token.cancelled?
|
|
241
|
+
|
|
168
242
|
i, task = begin
|
|
169
243
|
queue.pop(true)
|
|
170
244
|
rescue ThreadError
|
|
171
245
|
break # queue is empty; this worker is done
|
|
172
246
|
end
|
|
173
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
|
+
|
|
174
255
|
begin
|
|
175
256
|
results[i] = task[:agent].new.invoke(
|
|
176
257
|
task[:input],
|
|
177
|
-
config:
|
|
258
|
+
config: task_config,
|
|
259
|
+
thread_id: task[:thread_id]
|
|
178
260
|
)
|
|
179
261
|
rescue => e
|
|
180
262
|
case on_error
|
|
@@ -188,7 +270,37 @@ module Phronomy
|
|
|
188
270
|
end
|
|
189
271
|
end
|
|
190
272
|
|
|
191
|
-
workers.each(&:join)
|
|
273
|
+
workers.each(&:join) if timeout.nil?
|
|
274
|
+
|
|
275
|
+
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
|
|
281
|
+
|
|
282
|
+
alive = workers.select(&:alive?)
|
|
283
|
+
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
|
|
299
|
+
raise Phronomy::TimeoutError,
|
|
300
|
+
"dispatch_parallel timed out after #{timeout}s " \
|
|
301
|
+
"(#{alive.length} of #{workers.length} workers still running)"
|
|
302
|
+
end
|
|
303
|
+
end
|
|
192
304
|
|
|
193
305
|
first_error = errors.compact.first
|
|
194
306
|
raise first_error if first_error
|
|
@@ -18,6 +18,7 @@ module Phronomy
|
|
|
18
18
|
# {AgentFSM} IO thread (i.e. when the +:phronomy_agent_parallel_tools+
|
|
19
19
|
# thread-local flag is +true+). It is not used for direct synchronous
|
|
20
20
|
# +invoke+ calls so that the streaming callback state remains single-threaded.
|
|
21
|
+
# @api private
|
|
21
22
|
class ParallelToolChat < RubyLLM::Chat
|
|
22
23
|
private
|
|
23
24
|
|
|
@@ -34,6 +35,7 @@ module Phronomy
|
|
|
34
35
|
#
|
|
35
36
|
# @param response [RubyLLM::Message] the LLM response containing tool calls
|
|
36
37
|
# @yield streaming block forwarded to +complete+
|
|
38
|
+
# @api private
|
|
37
39
|
def handle_tool_calls(response, &block)
|
|
38
40
|
tool_calls = response.tool_calls.values
|
|
39
41
|
|
|
@@ -50,14 +52,29 @@ module Phronomy
|
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
# Phase 2 — parallel tool execution.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
# Honour the per-agent concurrency cap (max_parallel_tools DSL).
|
|
56
|
+
# Tool calls are processed in batches of at most `max` threads;
|
|
57
|
+
# batches run sequentially so the total in-flight thread count never
|
|
58
|
+
# exceeds the limit.
|
|
59
|
+
#
|
|
60
|
+
# Check for cancellation before dispatching each batch so that
|
|
61
|
+
# already-cancelled tokens do not start new LLM/tool-round-trips.
|
|
62
|
+
ct = Thread.current[:phronomy_cancellation_token]
|
|
63
|
+
max = Thread.current[:phronomy_max_parallel_tools] || 10
|
|
64
|
+
thread_results = tool_calls.each_slice(max).flat_map do |batch|
|
|
65
|
+
if ct&.cancelled?
|
|
66
|
+
raise Phronomy::CancellationError, "invocation cancelled before tool execution"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
threads = batch.map do |tool_call|
|
|
70
|
+
Thread.new { {tool_call: tool_call, result: execute_tool(tool_call)} }
|
|
71
|
+
end
|
|
72
|
+
threads.map(&:value)
|
|
55
73
|
end
|
|
56
|
-
results = thread_results.map(&:value)
|
|
57
74
|
|
|
58
75
|
# Phase 3 — post-execution callbacks and message recording (sequential).
|
|
59
76
|
halt_result = nil
|
|
60
|
-
|
|
77
|
+
thread_results.each do |item|
|
|
61
78
|
result = item[:result]
|
|
62
79
|
@on[:tool_result]&.call(result)
|
|
63
80
|
tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
|
|
@@ -37,9 +37,10 @@ module Phronomy
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
40
|
+
# Select the last assistant-produced content as the output, skipping
|
|
41
|
+
# raw tool result messages (role: :tool) to avoid returning tool JSON
|
|
42
|
+
# or status strings as the agent's answer when iterations are exhausted.
|
|
43
|
+
output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
|
|
43
44
|
|
|
44
45
|
# Run output guardrails before returning to the caller.
|
|
45
46
|
run_output_guardrails!(output)
|
|
@@ -60,6 +61,7 @@ module Phronomy
|
|
|
60
61
|
# @param config [Hash]
|
|
61
62
|
# @yield [Phronomy::Agent::StreamEvent]
|
|
62
63
|
# @return [Hash] { output:, messages:, usage: }
|
|
64
|
+
# @api public
|
|
63
65
|
def stream(input, messages: [], thread_id: nil, config: {}, &block)
|
|
64
66
|
return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
|
|
65
67
|
|
|
@@ -88,9 +90,9 @@ module Phronomy
|
|
|
88
90
|
end
|
|
89
91
|
end
|
|
90
92
|
|
|
91
|
-
#
|
|
92
|
-
# the non-streaming path
|
|
93
|
-
output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
|
|
93
|
+
# Select the last assistant-produced content as the output, skipping
|
|
94
|
+
# raw tool result messages (role: :tool) — same as the non-streaming path.
|
|
95
|
+
output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
|
|
94
96
|
run_output_guardrails!(output)
|
|
95
97
|
|
|
96
98
|
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
|
|
@@ -7,9 +7,13 @@ module Phronomy
|
|
|
7
7
|
# @see https://claude.com/blog/multi-agent-coordination-patterns
|
|
8
8
|
#
|
|
9
9
|
# A coordinator LLM agent decomposes work into tasks and enqueues them
|
|
10
|
-
# dynamically via built-in tools. A fixed
|
|
11
|
-
#
|
|
12
|
-
# assignments to accumulate domain context over time.
|
|
10
|
+
# dynamically via built-in tools. A fixed set of worker agents processes tasks
|
|
11
|
+
# sequentially — one task per worker per turn — carrying forward their
|
|
12
|
+
# conversation history across assignments to accumulate domain context over time.
|
|
13
|
+
#
|
|
14
|
+
# Workers are selected in sequence (the worker with the fewest accumulated
|
|
15
|
+
# messages is chosen by default). Task dispatch is synchronous; there is no
|
|
16
|
+
# concurrent or parallel execution.
|
|
13
17
|
#
|
|
14
18
|
# The coordinator is an {Agent::Base} subclass that has two built-in tools:
|
|
15
19
|
# - +enqueue_task+ — adds a task description to the queue
|
|
@@ -56,6 +60,7 @@ module Phronomy
|
|
|
56
60
|
# Falls back to +Phronomy.configuration.default_model+ when not set.
|
|
57
61
|
#
|
|
58
62
|
# @param value [String, nil]
|
|
63
|
+
# @api public
|
|
59
64
|
def coordinator_model(value = nil)
|
|
60
65
|
value ? @coordinator_model = value : @coordinator_model
|
|
61
66
|
end
|
|
@@ -65,6 +70,7 @@ module Phronomy
|
|
|
65
70
|
# and then call +finalize+ when all tasks are enqueued.
|
|
66
71
|
#
|
|
67
72
|
# @param value [String, nil]
|
|
73
|
+
# @api public
|
|
68
74
|
def coordinator_instructions(value = nil)
|
|
69
75
|
value ? @coordinator_instructions = value : @coordinator_instructions
|
|
70
76
|
end
|
|
@@ -75,16 +81,18 @@ module Phronomy
|
|
|
75
81
|
# Pass the same value as +LLMConfig::PROVIDER+ in your examples.
|
|
76
82
|
#
|
|
77
83
|
# @param value [Symbol, nil]
|
|
84
|
+
# @api public
|
|
78
85
|
def coordinator_provider(value = nil)
|
|
79
86
|
value ? @coordinator_provider = value : @coordinator_provider
|
|
80
87
|
end
|
|
81
88
|
|
|
82
|
-
# Configures the
|
|
89
|
+
# Configures the set of workers.
|
|
83
90
|
#
|
|
84
|
-
# @param size [Integer] number of persistent worker instances
|
|
91
|
+
# @param size [Integer] number of persistent worker instances (tasks are assigned sequentially)
|
|
85
92
|
# @param agent [Class] Agent::Base subclass used for all workers
|
|
86
93
|
# @param on_error [Symbol] +:raise+ (default) propagates worker exceptions;
|
|
87
94
|
# +:skip+ records the failure and continues with remaining tasks
|
|
95
|
+
# @api public
|
|
88
96
|
def pool(size:, agent:, on_error: :raise)
|
|
89
97
|
@pool_size = Integer(size)
|
|
90
98
|
@worker_agent = agent
|
|
@@ -98,6 +106,7 @@ module Phronomy
|
|
|
98
106
|
#
|
|
99
107
|
# @yield [Array<WorkerState>] available workers
|
|
100
108
|
# @yieldreturn [WorkerState] the chosen worker
|
|
109
|
+
# @api public
|
|
101
110
|
def schedule(&block)
|
|
102
111
|
@scheduler = block
|
|
103
112
|
end
|
|
@@ -108,6 +117,7 @@ module Phronomy
|
|
|
108
117
|
# When omitted, the raw assignments array is returned.
|
|
109
118
|
#
|
|
110
119
|
# @yield [Array<Hash>] all completed (and skipped) task assignments
|
|
120
|
+
# @api public
|
|
111
121
|
def aggregate(&block)
|
|
112
122
|
@aggregator = block
|
|
113
123
|
end
|
|
@@ -137,6 +147,7 @@ module Phronomy
|
|
|
137
147
|
# @param config [Hash] reserved for future use
|
|
138
148
|
# @return [Object] the return value of the aggregate block, or the raw assignments Array
|
|
139
149
|
# @raise [ArgumentError] when +pool :agent+ has not been configured
|
|
150
|
+
# @api public
|
|
140
151
|
def invoke(team_input, config: {})
|
|
141
152
|
raise ArgumentError, "pool :agent must be configured before invoking" unless self.class._worker_agent
|
|
142
153
|
|
|
@@ -161,6 +172,7 @@ module Phronomy
|
|
|
161
172
|
# @yield [Hash] one event per completed/failed task
|
|
162
173
|
# @return [Object] same as +invoke+
|
|
163
174
|
# @raise [ArgumentError] when +pool :agent+ has not been configured
|
|
175
|
+
# @api public
|
|
164
176
|
def stream(team_input, config: {}, &block)
|
|
165
177
|
return invoke(team_input, config: config) unless block
|
|
166
178
|
|