phronomy 0.5.4 → 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 +379 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +262 -48
- 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 +281 -13
- 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 +180 -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 +92 -0
- 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 +32 -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/context.rb +0 -1
- 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.rb +14 -0
- data/lib/phronomy/event_loop.rb +254 -0
- data/lib/phronomy/fsm_session.rb +201 -0
- data/lib/phronomy/generator_verifier.rb +24 -22
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail.rb +0 -1
- 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 +175 -74
- data/lib/phronomy/workflow_context.rb +55 -5
- data/lib/phronomy/workflow_runner.rb +197 -114
- data/lib/phronomy.rb +74 -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 +50 -6
- data/lib/phronomy/context/builder.rb +0 -92
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
- data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
- data/lib/phronomy/guardrail/builtin.rb +0 -16
|
@@ -7,6 +7,7 @@ module Phronomy
|
|
|
7
7
|
#
|
|
8
8
|
# Included in {Phronomy::Agent::Base}. The retry loop wraps the full
|
|
9
9
|
# #invoke_once call; {Phronomy::GuardrailError} is never retried.
|
|
10
|
+
# @api private
|
|
10
11
|
module Retryable
|
|
11
12
|
def self.included(base)
|
|
12
13
|
base.extend(ClassMethods)
|
|
@@ -25,6 +26,7 @@ module Phronomy
|
|
|
25
26
|
# class MyAgent < Phronomy::Agent::Base
|
|
26
27
|
# retry_policy times: 2, wait: :exponential, base: 1.0
|
|
27
28
|
# end
|
|
29
|
+
# @api private
|
|
28
30
|
def retry_policy(times: 0, wait: 0, base: 1.0)
|
|
29
31
|
@_retry_policy = {times: times, wait: wait, base: base}
|
|
30
32
|
end
|
|
@@ -35,6 +37,7 @@ module Phronomy
|
|
|
35
37
|
|
|
36
38
|
# Injectable sleep callable for testing (shared with Tool::Base pattern).
|
|
37
39
|
# @return [#call]
|
|
40
|
+
# @api private
|
|
38
41
|
def _sleep_proc
|
|
39
42
|
@_sleep_proc || method(:sleep)
|
|
40
43
|
end
|
|
@@ -48,12 +51,19 @@ module Phronomy
|
|
|
48
51
|
|
|
49
52
|
# Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
|
|
50
53
|
def _invoke_impl(input, messages: [], thread_id: nil, config: {})
|
|
54
|
+
# Fail fast when the token is already cancelled before any LLM call.
|
|
55
|
+
if (token = config[:cancellation_token]) && token.cancelled?
|
|
56
|
+
raise Phronomy::CancellationError, "invocation cancelled"
|
|
57
|
+
end
|
|
58
|
+
|
|
51
59
|
policy = self.class._retry_policy
|
|
52
60
|
attempt = 0
|
|
53
61
|
begin
|
|
54
62
|
invoke_once(input, messages: messages, thread_id: thread_id, config: config)
|
|
55
63
|
rescue Phronomy::GuardrailError
|
|
56
64
|
raise
|
|
65
|
+
rescue Phronomy::CancellationError
|
|
66
|
+
raise # Never retry after cancellation.
|
|
57
67
|
rescue
|
|
58
68
|
if policy && attempt < policy[:times]
|
|
59
69
|
wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
|
|
@@ -61,7 +71,7 @@ module Phronomy
|
|
|
61
71
|
attempt += 1
|
|
62
72
|
retry
|
|
63
73
|
end
|
|
64
|
-
|
|
74
|
+
translate_and_reraise!($!)
|
|
65
75
|
end
|
|
66
76
|
end
|
|
67
77
|
|
|
@@ -70,6 +80,7 @@ module Phronomy
|
|
|
70
80
|
# @param base [Float]
|
|
71
81
|
# @param attempt [Integer]
|
|
72
82
|
# @return [Float]
|
|
83
|
+
# @api private
|
|
73
84
|
def compute_agent_retry_wait(strategy, base, attempt)
|
|
74
85
|
case strategy
|
|
75
86
|
when :exponential
|
|
@@ -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 }
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
# EventLoop-registered execution unit for a single agent invocation.
|
|
8
|
+
#
|
|
9
|
+
# +AgentFSM+ implements the minimal interface expected by {Phronomy::EventLoop}
|
|
10
|
+
# (+#id+, +#start+, +#handle+) so it can be managed alongside
|
|
11
|
+
# {Phronomy::FSMSession} instances. It is *not* a traditional finite-state
|
|
12
|
+
# machine; the name reflects its role in the EventLoop rather than internal
|
|
13
|
+
# state transitions.
|
|
14
|
+
#
|
|
15
|
+
# == Execution model
|
|
16
|
+
#
|
|
17
|
+
# {#start} is called by the EventLoop on the +:start+ event. It immediately
|
|
18
|
+
# returns after spawning a background IO thread that runs the agent's full
|
|
19
|
+
# invocation pipeline (via +_invoke_impl+). The EventLoop thread is never
|
|
20
|
+
# blocked by agent execution.
|
|
21
|
+
#
|
|
22
|
+
# Inside the IO thread, the +:phronomy_agent_parallel_tools+ thread-local
|
|
23
|
+
# flag is set to +true+ so that {Agent::Base#build_chat} returns a
|
|
24
|
+
# {ParallelToolChat} instance, enabling concurrent tool dispatch when the LLM
|
|
25
|
+
# returns multiple tool calls in one response.
|
|
26
|
+
#
|
|
27
|
+
# == Completion events
|
|
28
|
+
#
|
|
29
|
+
# On *success*:
|
|
30
|
+
# - Posts +:finished+ to this FSM's own +#id+ so the EventLoop cleans up
|
|
31
|
+
# its registry entry and unblocks any +completion_queue.pop+ caller.
|
|
32
|
+
# - When +parent_id+ is set (child-FSM pattern), additionally posts
|
|
33
|
+
# +:child_completed+ to +parent_id+, carrying the result hash as the
|
|
34
|
+
# event payload. The parent {FSMSession} must declare an +on:+ transition
|
|
35
|
+
# for +:child_completed+ to advance correctly.
|
|
36
|
+
#
|
|
37
|
+
# On *error*:
|
|
38
|
+
# - Posts +:error+ to this FSM's own +#id+. The EventLoop propagates the
|
|
39
|
+
# exception through the +completion_queue+ so that the original caller of
|
|
40
|
+
# +Agent::Base#invoke+ (in EventLoop mode) receives and re-raises it.
|
|
41
|
+
#
|
|
42
|
+
# == Standalone usage (blocking caller)
|
|
43
|
+
#
|
|
44
|
+
# Phronomy.configure { |c| c.event_loop = true }
|
|
45
|
+
# result = MyAgent.new.invoke("Hello!") # => { output:, messages:, usage: }
|
|
46
|
+
#
|
|
47
|
+
# {Agent::Base#invoke} detects EventLoop mode, creates an +AgentFSM+, registers
|
|
48
|
+
# it via {EventLoop#register}, and blocks the *calling* thread on the returned
|
|
49
|
+
# +completion_queue+ until the agent finishes.
|
|
50
|
+
#
|
|
51
|
+
# == Child-FSM usage (non-blocking, inside a Workflow)
|
|
52
|
+
#
|
|
53
|
+
# state :run_agent
|
|
54
|
+
# entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
|
|
55
|
+
# transition from: :run_agent, on: :child_completed, to: :process_result
|
|
56
|
+
#
|
|
57
|
+
# {Agent::Base#run_as_child} creates an +AgentFSM+ with +parent_id+ set to
|
|
58
|
+
# +ctx.thread_id+, registers it with the EventLoop, and returns immediately.
|
|
59
|
+
# The parent {FSMSession} waits for the +:child_completed+ event.
|
|
60
|
+
# @api private
|
|
61
|
+
class FSM
|
|
62
|
+
# @return [String] unique identifier used as the EventLoop target_id
|
|
63
|
+
attr_reader :id
|
|
64
|
+
|
|
65
|
+
# @return [Symbol] current internal phase (:idle, :running)
|
|
66
|
+
attr_reader :current_phase
|
|
67
|
+
|
|
68
|
+
# @param agent [Phronomy::Agent::Base] agent instance to run
|
|
69
|
+
# @param input [String, Hash] user input passed to +invoke_once+
|
|
70
|
+
# @param messages [Array] prior conversation history
|
|
71
|
+
# @param thread_id [String, nil] conversation thread id;
|
|
72
|
+
# auto-generated when nil
|
|
73
|
+
# @param config [Hash] invocation config forwarded to
|
|
74
|
+
# +_invoke_impl+
|
|
75
|
+
# @param parent_id [String, nil] EventLoop id of the parent
|
|
76
|
+
# FSMSession; when set, a
|
|
77
|
+
# +:child_completed+ event is posted
|
|
78
|
+
# on completion
|
|
79
|
+
# @param result_writer [Proc, nil] optional callable invoked with the
|
|
80
|
+
# result hash <b>before</b>
|
|
81
|
+
# +:child_completed+ is posted.
|
|
82
|
+
# Use this to write the agent output
|
|
83
|
+
# back into the parent WorkflowContext.
|
|
84
|
+
# Thread::Queue provides the
|
|
85
|
+
# happens-before guarantee.
|
|
86
|
+
#
|
|
87
|
+
# @example Writing result into context
|
|
88
|
+
# entry :run_agent, ->(ctx) {
|
|
89
|
+
# MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
|
|
90
|
+
# }
|
|
91
|
+
# @api private
|
|
92
|
+
def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil, result_writer: nil)
|
|
93
|
+
@agent = agent
|
|
94
|
+
@input = input
|
|
95
|
+
@messages = Array(messages).dup
|
|
96
|
+
@thread_id = thread_id || SecureRandom.uuid
|
|
97
|
+
@config = config
|
|
98
|
+
@parent_id = parent_id
|
|
99
|
+
@result_writer = result_writer
|
|
100
|
+
@id = @thread_id
|
|
101
|
+
@current_phase = :idle
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Called by {EventLoop} on the +:start+ event.
|
|
105
|
+
# Transitions to +:running+ and spawns the agent IO thread.
|
|
106
|
+
def start
|
|
107
|
+
@current_phase = :running
|
|
108
|
+
spawn_agent_thread
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Called by {EventLoop} for external events dispatched to this id.
|
|
112
|
+
# +AgentFSM+ is fully driven by its own IO thread and does not respond
|
|
113
|
+
# to external events after {#start}.
|
|
114
|
+
def handle(_event)
|
|
115
|
+
# No-op: AgentFSM is driven entirely by its IO thread.
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Spawns the background IO thread that runs the agent invocation.
|
|
121
|
+
# Captures all instance variables by value so the thread closure is
|
|
122
|
+
# safe even if the FSM object is modified (though it is not in practice).
|
|
123
|
+
def spawn_agent_thread
|
|
124
|
+
agent = @agent
|
|
125
|
+
input = @input
|
|
126
|
+
messages = @messages
|
|
127
|
+
thread_id = @thread_id
|
|
128
|
+
config = @config
|
|
129
|
+
fsm_id = @id
|
|
130
|
+
parent_id = @parent_id
|
|
131
|
+
result_writer = @result_writer
|
|
132
|
+
|
|
133
|
+
Thread.new do
|
|
134
|
+
# Enable parallel tool dispatch inside this IO thread.
|
|
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
|
|
139
|
+
|
|
140
|
+
begin
|
|
141
|
+
result = agent.send(:_invoke_impl,
|
|
142
|
+
input,
|
|
143
|
+
messages: messages,
|
|
144
|
+
thread_id: thread_id,
|
|
145
|
+
config: config)
|
|
146
|
+
|
|
147
|
+
if parent_id
|
|
148
|
+
# Let the caller write the result into the context BEFORE the
|
|
149
|
+
# parent FSMSession advances. Thread::Queue provides the
|
|
150
|
+
# happens-before guarantee — no Mutex needed.
|
|
151
|
+
result_writer&.call(result)
|
|
152
|
+
|
|
153
|
+
Phronomy::EventLoop.instance.post(
|
|
154
|
+
Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Phronomy::EventLoop.instance.post(
|
|
159
|
+
Phronomy::Event.new(type: :finished, target_id: fsm_id, payload: result)
|
|
160
|
+
)
|
|
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
|
+
|
|
168
|
+
Phronomy::EventLoop.instance.post(
|
|
169
|
+
Phronomy::Event.new(type: :error, target_id: fsm_id, payload: e)
|
|
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)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
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
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
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 in a dedicated IO thread and all results are collected
|
|
9
|
+
# before being appended to the message history. This preserves a
|
|
10
|
+
# deterministic message order while reducing wall-clock latency when tools
|
|
11
|
+
# are IO-bound (HTTP calls, DB queries, etc.).
|
|
12
|
+
#
|
|
13
|
+
# Single-tool responses fall through to the standard sequential path via
|
|
14
|
+
# +super+, preserving all existing edge-case behaviour (Tool::Halt,
|
|
15
|
+
# forced_tool_choice, streaming, SuspendSignal, etc.).
|
|
16
|
+
#
|
|
17
|
+
# This class is used automatically when the agent is running inside an
|
|
18
|
+
# {AgentFSM} IO thread (i.e. when the +:phronomy_agent_parallel_tools+
|
|
19
|
+
# thread-local flag is +true+). It is not used for direct synchronous
|
|
20
|
+
# +invoke+ calls so that the streaming callback state remains single-threaded.
|
|
21
|
+
# @api private
|
|
22
|
+
class ParallelToolChat < RubyLLM::Chat
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
|
|
26
|
+
# when multiple tool calls are present in a single LLM response.
|
|
27
|
+
#
|
|
28
|
+
# The method preserves the three-phase protocol of the original:
|
|
29
|
+
# 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
|
|
30
|
+
# sequential so that the Suspendable concern's approval hook can
|
|
31
|
+
# raise +SuspendSignal+ before any tool is executed.
|
|
32
|
+
# 2. Parallel tool execution — one IO thread per tool call.
|
|
33
|
+
# 3. Post-execution callbacks and message recording — sequential,
|
|
34
|
+
# in the original tool-call order.
|
|
35
|
+
#
|
|
36
|
+
# @param response [RubyLLM::Message] the LLM response containing tool calls
|
|
37
|
+
# @yield streaming block forwarded to +complete+
|
|
38
|
+
# @api private
|
|
39
|
+
def handle_tool_calls(response, &block)
|
|
40
|
+
tool_calls = response.tool_calls.values
|
|
41
|
+
|
|
42
|
+
# Single tool: delegate to the parent implementation to preserve every
|
|
43
|
+
# edge case (forced_tool_choice, streaming, Halt, SuspendSignal…).
|
|
44
|
+
return super if tool_calls.size <= 1
|
|
45
|
+
|
|
46
|
+
# Phase 1 — pre-execution callbacks (sequential, original order).
|
|
47
|
+
# The SuspendSignal approval hook is registered via on_tool_call, so it
|
|
48
|
+
# MUST fire before execution begins.
|
|
49
|
+
tool_calls.each do |tool_call|
|
|
50
|
+
@on[:new_message]&.call
|
|
51
|
+
@on[:tool_call]&.call(tool_call)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Phase 2 — parallel tool execution.
|
|
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)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Phase 3 — post-execution callbacks and message recording (sequential).
|
|
76
|
+
halt_result = nil
|
|
77
|
+
thread_results.each do |item|
|
|
78
|
+
result = item[:result]
|
|
79
|
+
@on[:tool_result]&.call(result)
|
|
80
|
+
tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
|
|
81
|
+
content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
|
|
82
|
+
message = add_message(role: :tool, content: content, tool_call_id: item[:tool_call].id)
|
|
83
|
+
@on[:end_message]&.call(message)
|
|
84
|
+
halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
reset_tool_choice if forced_tool_choice?
|
|
88
|
+
halt_result || complete(&block)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -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
|