phronomy 0.9.0 → 0.10.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/CHANGELOG.md +96 -4
- data/README.md +12 -12
- data/lib/phronomy/agent/base.rb +106 -96
- data/lib/phronomy/agent/checkpoint.rb +30 -1
- data/lib/phronomy/agent/checkpoint_store.rb +97 -0
- data/lib/phronomy/agent/concerns/retryable.rb +1 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +57 -2
- data/lib/phronomy/configuration.rb +13 -0
- data/lib/phronomy/event_loop.rb +1 -18
- data/lib/phronomy/task/mapped_backend.rb +78 -0
- data/lib/phronomy/task.rb +53 -0
- data/lib/phronomy/tools/agent.rb +2 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/fsm_session.rb +249 -0
- data/lib/phronomy/workflow/phase_machine_builder.rb +247 -0
- data/lib/phronomy/workflow_runner.rb +2 -2
- data/lib/phronomy.rb +10 -3
- data/scripts/api_snapshot.rb +0 -1
- metadata +6 -7
- data/lib/phronomy/agent/fsm.rb +0 -157
- data/lib/phronomy/agent/invocation_pipeline.rb +0 -108
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +0 -251
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +0 -249
- data/lib/phronomy/agent/react_agent.rb +0 -205
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "state_machines"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
class Workflow
|
|
7
|
+
# Builds the anonymous state-machine Class used by {WorkflowRunner} to track
|
|
8
|
+
# workflow phase transitions.
|
|
9
|
+
#
|
|
10
|
+
# Extracted from {WorkflowRunner#build_phase_machine_class} to reduce the
|
|
11
|
+
# span of WorkflowRunner's initializer and to give the FSM construction
|
|
12
|
+
# logic an explicit, testable home.
|
|
13
|
+
#
|
|
14
|
+
# Call {#build} to obtain the generated +Class+. The returned class responds
|
|
15
|
+
# to +#context+ / +#context=+ and +#async_pending+ / +#async_pending=+, and
|
|
16
|
+
# has a +state_machine :phase+ definition with all registered transitions and
|
|
17
|
+
# callbacks.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
class PhaseMachineBuilder
|
|
21
|
+
# @param entry_point [Symbol] initial state for the phase machine
|
|
22
|
+
# @param declared_states [Array<Symbol>] all states declared in the workflow
|
|
23
|
+
# @param wait_state_names [Array<Symbol>] states that wait for external events
|
|
24
|
+
# @param external_events [Hash{Symbol => Array<Hash>}]
|
|
25
|
+
# +{ event_name => [{from:, to:, guard:}, ...] }+
|
|
26
|
+
# @param entry_actions [Hash{Symbol => Array<#call>}]
|
|
27
|
+
# +{ state_name => [callable, ...] }+
|
|
28
|
+
# @param action_timeouts [Hash{Symbol => Numeric}]
|
|
29
|
+
# +{ state_name => seconds }+
|
|
30
|
+
# @param auto_transitions [Array<Hash>]
|
|
31
|
+
# +[{ from:, to:, guard: }, ...]+ — all auto-fire transitions
|
|
32
|
+
# @param exit_actions [Hash{Symbol => Array<#call>}]
|
|
33
|
+
# +{ state_name => [callable, ...] }+
|
|
34
|
+
# @api private
|
|
35
|
+
def initialize(
|
|
36
|
+
entry_point:,
|
|
37
|
+
declared_states:,
|
|
38
|
+
wait_state_names:,
|
|
39
|
+
external_events:,
|
|
40
|
+
entry_actions:,
|
|
41
|
+
action_timeouts:,
|
|
42
|
+
auto_transitions:,
|
|
43
|
+
exit_actions:
|
|
44
|
+
)
|
|
45
|
+
@entry_point = entry_point
|
|
46
|
+
@declared_states = declared_states
|
|
47
|
+
@wait_state_names = wait_state_names
|
|
48
|
+
@external_events = external_events
|
|
49
|
+
@entry_actions = entry_actions
|
|
50
|
+
@action_timeouts = action_timeouts
|
|
51
|
+
@auto_transitions = auto_transitions
|
|
52
|
+
@exit_actions = exit_actions
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Constructs and returns the anonymous phase-machine Class.
|
|
56
|
+
#
|
|
57
|
+
# @return [Class] an anonymous class with a +state_machine :phase+ definition
|
|
58
|
+
# @raise [ArgumentError] if state_machines raises during class construction
|
|
59
|
+
# @api private
|
|
60
|
+
def build
|
|
61
|
+
entry = @entry_point
|
|
62
|
+
all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
|
|
63
|
+
auto_trans = @auto_transitions
|
|
64
|
+
ext_events = @external_events
|
|
65
|
+
entry_acts = @entry_actions
|
|
66
|
+
exit_acts = @exit_actions
|
|
67
|
+
act_timeouts = @action_timeouts
|
|
68
|
+
build_cb = method(:build_entry_callback)
|
|
69
|
+
|
|
70
|
+
Class.new do
|
|
71
|
+
# Holds the current WorkflowContext so guards and callbacks can read it.
|
|
72
|
+
attr_accessor :context
|
|
73
|
+
|
|
74
|
+
# Set to true by an entry action that returned an awaitable Task.
|
|
75
|
+
# When true, FSMSession skips the automatic advance_or_halt step and
|
|
76
|
+
# waits for the async worker thread to post a state_completed event back.
|
|
77
|
+
attr_accessor :async_pending
|
|
78
|
+
|
|
79
|
+
state_machine :phase, initial: entry do
|
|
80
|
+
all_states.each { |s| state s }
|
|
81
|
+
|
|
82
|
+
# Auto-fire transitions: all auto transitions unified under :state_completed.
|
|
83
|
+
# Includes unguarded (unconditional) and guarded (conditional) transitions.
|
|
84
|
+
# Declaration order is preserved; guards are evaluated before unguarded fallbacks.
|
|
85
|
+
event :state_completed do
|
|
86
|
+
auto_trans.each do |t|
|
|
87
|
+
if t[:guard]
|
|
88
|
+
guard_proc = t[:guard]
|
|
89
|
+
transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
|
|
90
|
+
else
|
|
91
|
+
transition t[:from] => t[:to]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# External events: human-in-the-loop triggers from wait states.
|
|
97
|
+
ext_events.each do |ev_name, transitions|
|
|
98
|
+
event ev_name do
|
|
99
|
+
transitions.each do |t|
|
|
100
|
+
if t[:guard]
|
|
101
|
+
guard_proc = t[:guard]
|
|
102
|
+
transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
|
|
103
|
+
else
|
|
104
|
+
transition t[:from] => t[:to]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Entry callbacks: fire after_transition into each state.
|
|
111
|
+
# Each callable is registered as a separate callback; state_machines
|
|
112
|
+
# accumulates them and fires in declaration order.
|
|
113
|
+
# If the callable returns a WorkflowContext (e.g. via s.merge(...)),
|
|
114
|
+
# the returned context replaces the current one on the tracker.
|
|
115
|
+
entry_acts.each do |state_name, callables|
|
|
116
|
+
callables.each do |callable|
|
|
117
|
+
cb = build_cb.call(callable, state_name, act_timeouts[state_name])
|
|
118
|
+
after_transition to: state_name, &cb
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Exit callbacks: fire before_transition out of each state.
|
|
123
|
+
# Each callable is registered as a separate callback; state_machines
|
|
124
|
+
# accumulates them and fires in declaration order.
|
|
125
|
+
exit_acts.each do |state_name, callables|
|
|
126
|
+
callables.each do |callable|
|
|
127
|
+
before_transition from: state_name do |machine|
|
|
128
|
+
callable.call(machine.context)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
rescue => e
|
|
135
|
+
raise ArgumentError, "Failed to build phase machine: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# Returns a proc suitable for use as an +after_transition+ callback.
|
|
141
|
+
#
|
|
142
|
+
# The returned proc accepts a single argument (the phase machine instance),
|
|
143
|
+
# invokes the entry action callable with the current context, then delegates
|
|
144
|
+
# the result to {#handle_entry_action_result}. Capturing this in the
|
|
145
|
+
# builder's scope lets the anonymous +Class.new+ block stay slim.
|
|
146
|
+
#
|
|
147
|
+
# @param callable [#call] the entry action
|
|
148
|
+
# @param state_name [Symbol] name of the target state (for error messages)
|
|
149
|
+
# @param timeout_secs [Numeric, nil] seconds before ActionTimeoutError
|
|
150
|
+
# @return [Proc]
|
|
151
|
+
# @api private
|
|
152
|
+
def build_entry_callback(callable, state_name, timeout_secs)
|
|
153
|
+
handle = method(:handle_entry_action_result)
|
|
154
|
+
->(machine) {
|
|
155
|
+
result = callable.call(machine.context)
|
|
156
|
+
handle.call(machine, result, state_name, timeout_secs)
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Dispatches the return value of an entry action callable.
|
|
161
|
+
#
|
|
162
|
+
# - +Phronomy::Task+ → async or blocking task handling
|
|
163
|
+
# - +Phronomy::WorkflowContext+ → replaces the machine's context directly
|
|
164
|
+
# - anything else → ignored
|
|
165
|
+
#
|
|
166
|
+
# @param machine [Object] phase machine instance
|
|
167
|
+
# @param result [Object] return value of the entry callable
|
|
168
|
+
# @param state_name [Symbol] name of the entered state
|
|
169
|
+
# @param timeout_secs [Numeric, nil] optional timeout in seconds
|
|
170
|
+
# @api private
|
|
171
|
+
def handle_entry_action_result(machine, result, state_name, timeout_secs)
|
|
172
|
+
if result.is_a?(Phronomy::Task)
|
|
173
|
+
if Phronomy.configuration.event_loop
|
|
174
|
+
dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
|
|
175
|
+
else
|
|
176
|
+
await_task_blocking(machine, result, state_name, timeout_secs)
|
|
177
|
+
end
|
|
178
|
+
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
179
|
+
machine.context = result
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Handles a +Phronomy::Task+ return value in EventLoop mode.
|
|
184
|
+
#
|
|
185
|
+
# Marks the machine as async-pending and spawns a cooperative background
|
|
186
|
+
# task that awaits the result, then posts the appropriate event back to
|
|
187
|
+
# the EventLoop. +FSMSession+ will skip the automatic +advance_or_halt+
|
|
188
|
+
# step while +async_pending+ is true.
|
|
189
|
+
#
|
|
190
|
+
# @param machine [Object] phase machine instance
|
|
191
|
+
# @param result [Phronomy::Task]
|
|
192
|
+
# @param state_name [Symbol]
|
|
193
|
+
# @param timeout_secs [Numeric, nil]
|
|
194
|
+
# @api private
|
|
195
|
+
def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
|
|
196
|
+
machine.async_pending = true
|
|
197
|
+
thread_id = machine.context.thread_id
|
|
198
|
+
Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
|
|
199
|
+
enforce_timeout!(result, state_name, timeout_secs)
|
|
200
|
+
task_result = result.await
|
|
201
|
+
ev = if task_result.is_a?(Phronomy::WorkflowContext)
|
|
202
|
+
Phronomy::Event.new(type: :action_completed, target_id: thread_id, payload: task_result)
|
|
203
|
+
else
|
|
204
|
+
Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
|
|
205
|
+
end
|
|
206
|
+
Phronomy::EventLoop.instance.post(ev)
|
|
207
|
+
rescue => e
|
|
208
|
+
Phronomy::EventLoop.instance.post(
|
|
209
|
+
Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Handles a +Phronomy::Task+ return value in non-EventLoop mode.
|
|
215
|
+
#
|
|
216
|
+
# Blocks the current execution context until the task completes or the
|
|
217
|
+
# optional timeout elapses.
|
|
218
|
+
#
|
|
219
|
+
# @param machine [Object] phase machine instance
|
|
220
|
+
# @param result [Phronomy::Task]
|
|
221
|
+
# @param state_name [Symbol]
|
|
222
|
+
# @param timeout_secs [Numeric, nil]
|
|
223
|
+
# @api private
|
|
224
|
+
def await_task_blocking(machine, result, state_name, timeout_secs)
|
|
225
|
+
enforce_timeout!(result, state_name, timeout_secs)
|
|
226
|
+
task_result = result.await
|
|
227
|
+
machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Raises +ActionTimeoutError+ if the task does not complete within
|
|
231
|
+
# +timeout_secs+. No-op when +timeout_secs+ is +nil+.
|
|
232
|
+
#
|
|
233
|
+
# @param result [Phronomy::Task]
|
|
234
|
+
# @param state_name [Symbol]
|
|
235
|
+
# @param timeout_secs [Numeric, nil]
|
|
236
|
+
# @api private
|
|
237
|
+
def enforce_timeout!(result, state_name, timeout_secs)
|
|
238
|
+
return unless timeout_secs
|
|
239
|
+
return unless result.join(timeout_secs).nil?
|
|
240
|
+
|
|
241
|
+
result.cancel!
|
|
242
|
+
raise Phronomy::ActionTimeoutError,
|
|
243
|
+
"Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -56,7 +56,7 @@ module Phronomy
|
|
|
56
56
|
@wait_state_names = wait_state_names
|
|
57
57
|
@state_store = state_store
|
|
58
58
|
@action_timeouts = action_timeouts # { state_name => seconds }
|
|
59
|
-
@phase_machine_class =
|
|
59
|
+
@phase_machine_class = Workflow::PhaseMachineBuilder.new(
|
|
60
60
|
entry_point: @entry_point,
|
|
61
61
|
declared_states: @declared_states,
|
|
62
62
|
wait_state_names: @wait_state_names,
|
|
@@ -169,7 +169,7 @@ module Phronomy
|
|
|
169
169
|
|
|
170
170
|
# Builds an FSMSession for the given context. Used in EventLoop mode.
|
|
171
171
|
def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
|
|
172
|
-
Phronomy::
|
|
172
|
+
Phronomy::Workflow::FSMSession.new(
|
|
173
173
|
id: context.thread_id,
|
|
174
174
|
context: context,
|
|
175
175
|
entry_point: @entry_point,
|
data/lib/phronomy.rb
CHANGED
|
@@ -12,8 +12,6 @@ loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
|
|
|
12
12
|
loader.inflector.inflect("rag" => "RAG")
|
|
13
13
|
# FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
|
|
14
14
|
loader.inflector.inflect("fsm_session" => "FSMSession")
|
|
15
|
-
# AgentFSM: Zeitwerk would infer "Fsm" — override to "FSM".
|
|
16
|
-
loader.inflector.inflect("fsm" => "FSM")
|
|
17
15
|
# LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
|
|
18
16
|
loader.inflector.inflect("llm_adapter" => "LLMAdapter")
|
|
19
17
|
# LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
|
|
@@ -98,6 +96,14 @@ module Phronomy
|
|
|
98
96
|
end
|
|
99
97
|
end
|
|
100
98
|
|
|
99
|
+
# Raised when {Agent::Base#resume} (or the class-level equivalent) is called
|
|
100
|
+
# with a {Agent::Checkpoint} whose +checkpoint_id+ has already been consumed
|
|
101
|
+
# by a previous +resume+ call on the same store.
|
|
102
|
+
#
|
|
103
|
+
# This protects against duplicate resume executions caused by webhook retries
|
|
104
|
+
# or queue message redelivery.
|
|
105
|
+
class CheckpointAlreadyResumedError < Error; end
|
|
106
|
+
|
|
101
107
|
# Raised when an operation is submitted to a {BlockingAdapterPool} that has
|
|
102
108
|
# already been shut down via {BlockingAdapterPool#shutdown}.
|
|
103
109
|
class PoolShutdownError < Error; end
|
|
@@ -117,7 +123,8 @@ module Phronomy
|
|
|
117
123
|
# Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
|
|
118
124
|
# that does not own the context (i.e. not the EventLoop dispatch thread).
|
|
119
125
|
# Only raised in EventLoop mode. Use +context.merge(...)+ to produce a new
|
|
120
|
-
# context, or deliver updates as +:
|
|
126
|
+
# context, or deliver updates as +:action_completed+ event payloads
|
|
127
|
+
# via {Agent::Base#invoke_async} + {Task#map}.
|
|
121
128
|
class WorkflowContextOwnershipError < Error; end
|
|
122
129
|
|
|
123
130
|
class << self
|
data/scripts/api_snapshot.rb
CHANGED
|
@@ -30,7 +30,6 @@ PUBLIC_API_ENTRIES = [
|
|
|
30
30
|
Phronomy::Runnable,
|
|
31
31
|
Phronomy::Agent::Context::Instruction::PromptTemplate,
|
|
32
32
|
# Beta
|
|
33
|
-
Phronomy::Agent::ReactAgent,
|
|
34
33
|
Phronomy::MultiAgent::Orchestrator,
|
|
35
34
|
Phronomy::MultiAgent::TeamCoordinator,
|
|
36
35
|
Phronomy::Guardrail::InputGuardrail,
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phronomy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Raizo T.C.S
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ruby_llm
|
|
@@ -106,6 +106,7 @@ files:
|
|
|
106
106
|
- lib/phronomy/agent/base.rb
|
|
107
107
|
- lib/phronomy/agent/before_completion_context.rb
|
|
108
108
|
- lib/phronomy/agent/checkpoint.rb
|
|
109
|
+
- lib/phronomy/agent/checkpoint_store.rb
|
|
109
110
|
- lib/phronomy/agent/concerns/before_completion.rb
|
|
110
111
|
- lib/phronomy/agent/concerns/error_translation.rb
|
|
111
112
|
- lib/phronomy/agent/concerns/guardrailable.rb
|
|
@@ -117,11 +118,6 @@ files:
|
|
|
117
118
|
- lib/phronomy/agent/context/knowledge/base.rb
|
|
118
119
|
- lib/phronomy/agent/context/knowledge/entity_knowledge.rb
|
|
119
120
|
- lib/phronomy/agent/context/knowledge/static_knowledge.rb
|
|
120
|
-
- lib/phronomy/agent/fsm.rb
|
|
121
|
-
- lib/phronomy/agent/invocation_pipeline.rb
|
|
122
|
-
- lib/phronomy/agent/lifecycle/fsm_session.rb
|
|
123
|
-
- lib/phronomy/agent/lifecycle/phase_machine_builder.rb
|
|
124
|
-
- lib/phronomy/agent/react_agent.rb
|
|
125
121
|
- lib/phronomy/agent/runner.rb
|
|
126
122
|
- lib/phronomy/agent/shared_state.rb
|
|
127
123
|
- lib/phronomy/agent/suspend_signal.rb
|
|
@@ -193,6 +189,7 @@ files:
|
|
|
193
189
|
- lib/phronomy/task/backend.rb
|
|
194
190
|
- lib/phronomy/task/fiber_backend.rb
|
|
195
191
|
- lib/phronomy/task/immediate_backend.rb
|
|
192
|
+
- lib/phronomy/task/mapped_backend.rb
|
|
196
193
|
- lib/phronomy/task/thread_backend.rb
|
|
197
194
|
- lib/phronomy/task_group.rb
|
|
198
195
|
- lib/phronomy/testing.rb
|
|
@@ -226,6 +223,8 @@ files:
|
|
|
226
223
|
- lib/phronomy/vector_store/splitter/recursive_splitter.rb
|
|
227
224
|
- lib/phronomy/version.rb
|
|
228
225
|
- lib/phronomy/workflow.rb
|
|
226
|
+
- lib/phronomy/workflow/fsm_session.rb
|
|
227
|
+
- lib/phronomy/workflow/phase_machine_builder.rb
|
|
229
228
|
- lib/phronomy/workflow_context.rb
|
|
230
229
|
- lib/phronomy/workflow_runner.rb
|
|
231
230
|
- scripts/api_snapshot.rb
|
data/lib/phronomy/agent/fsm.rb
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
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::Agent::Lifecycle::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 {Phronomy::Task} 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 task, {Agent::Base#build_chat} returns a
|
|
23
|
-
# {ParallelToolChat} instance when EventLoop mode is enabled, allowing
|
|
24
|
-
# concurrent tool dispatch when the LLM returns multiple tool calls in one
|
|
25
|
-
# 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 FSMSession;
|
|
76
|
-
# when set, a +:child_completed+ event
|
|
77
|
-
# is posted on completion. The result
|
|
78
|
-
# is delivered exclusively as the event
|
|
79
|
-
# payload — no cross-thread writes to the
|
|
80
|
-
# parent WorkflowContext are performed.
|
|
81
|
-
#
|
|
82
|
-
# @api private
|
|
83
|
-
def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil)
|
|
84
|
-
@agent = agent
|
|
85
|
-
@input = input
|
|
86
|
-
@messages = Array(messages).dup
|
|
87
|
-
@thread_id = thread_id || SecureRandom.uuid
|
|
88
|
-
@config = config
|
|
89
|
-
@parent_id = parent_id
|
|
90
|
-
@id = @thread_id
|
|
91
|
-
@current_phase = :idle
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Called by {EventLoop} on the +:start+ event.
|
|
95
|
-
# Transitions to +:running+ and spawns the agent task.
|
|
96
|
-
def start
|
|
97
|
-
@current_phase = :running
|
|
98
|
-
spawn_agent_task
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Called by {EventLoop} for external events dispatched to this id.
|
|
102
|
-
# +AgentFSM+ is fully driven by its own IO thread and does not respond
|
|
103
|
-
# to external events after {#start}.
|
|
104
|
-
def handle(_event)
|
|
105
|
-
# No-op: AgentFSM is driven entirely by its IO thread.
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
private
|
|
109
|
-
|
|
110
|
-
# Spawns a {Phronomy::Task} that runs the agent invocation pipeline.
|
|
111
|
-
# Captures all instance variables by value so the task closure is
|
|
112
|
-
# safe even if the FSM object is modified (though it is not in practice).
|
|
113
|
-
def spawn_agent_task
|
|
114
|
-
agent = @agent
|
|
115
|
-
input = @input
|
|
116
|
-
messages = @messages
|
|
117
|
-
thread_id = @thread_id
|
|
118
|
-
config = @config
|
|
119
|
-
fsm_id = @id
|
|
120
|
-
parent_id = @parent_id
|
|
121
|
-
|
|
122
|
-
Phronomy::Runtime.instance.spawn(name: "agent-fsm:#{fsm_id}") do
|
|
123
|
-
result = agent.send(:_invoke_impl,
|
|
124
|
-
input,
|
|
125
|
-
messages: messages,
|
|
126
|
-
thread_id: thread_id,
|
|
127
|
-
config: config)
|
|
128
|
-
|
|
129
|
-
if parent_id
|
|
130
|
-
# Result is delivered exclusively as the :child_completed payload.
|
|
131
|
-
# The parent Workflow task is the sole owner of WorkflowContext
|
|
132
|
-
# and applies the result after receiving the event.
|
|
133
|
-
Phronomy::EventLoop.instance.post(
|
|
134
|
-
Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
|
|
135
|
-
)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
Phronomy::EventLoop.instance.post(
|
|
139
|
-
Phronomy::Event.new(type: :finished, target_id: fsm_id, payload: result)
|
|
140
|
-
)
|
|
141
|
-
rescue => e
|
|
142
|
-
if parent_id
|
|
143
|
-
Phronomy::EventLoop.instance.post(
|
|
144
|
-
Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
|
|
145
|
-
)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
Phronomy::EventLoop.instance.post(
|
|
149
|
-
Phronomy::Event.new(type: :error, target_id: fsm_id, payload: e)
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
# Context caches are instance variables; no thread-local cleanup needed.
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
end
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Agent
|
|
5
|
-
# Encapsulates the core per-invocation LLM round-trip for {Agent::Base}.
|
|
6
|
-
#
|
|
7
|
-
# {Agent::Base#invoke_once} delegates the body of each LLM turn to this
|
|
8
|
-
# class, keeping the caller to a thin setup + trace frame (span≈2).
|
|
9
|
-
# The pipeline executes inside the agent's binding via +instance_exec+
|
|
10
|
-
# so that private concern methods (guardrails, hooks, cancellation) remain
|
|
11
|
-
# encapsulated in their original modules while the orchestration logic lives
|
|
12
|
-
# here.
|
|
13
|
-
#
|
|
14
|
-
# @api private
|
|
15
|
-
class InvocationPipeline
|
|
16
|
-
# @param agent [Agent::Base] the agent instance driving this invocation
|
|
17
|
-
# @api private
|
|
18
|
-
def initialize(agent)
|
|
19
|
-
@agent = agent
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Runs one LLM round-trip inside the agent's execution context.
|
|
23
|
-
#
|
|
24
|
-
# Calls private {Agent::Base} concern methods (guardrails, hooks,
|
|
25
|
-
# cancellation) via +instance_exec+ so that their encapsulation is
|
|
26
|
-
# preserved, then routes the LLM request through the configured adapter.
|
|
27
|
-
#
|
|
28
|
-
# @param input [String, Hash] the user input for this turn
|
|
29
|
-
# @param messages [Array] prior conversation messages
|
|
30
|
-
# @param thread_id [String, nil] persistence thread identifier
|
|
31
|
-
# @param config [Hash] per-invocation options
|
|
32
|
-
# @return [Array(Hash, Phronomy::TokenUsage, nil)]
|
|
33
|
-
# A two-element array: the result hash and the token usage (or nil on
|
|
34
|
-
# suspension).
|
|
35
|
-
# @api private
|
|
36
|
-
def run(input, messages:, thread_id:, config:)
|
|
37
|
-
@agent.instance_exec(input, messages, thread_id, config) do |inp, msgs, tid, cfg|
|
|
38
|
-
# Run input guardrails before touching the LLM.
|
|
39
|
-
run_input_guardrails!(inp)
|
|
40
|
-
|
|
41
|
-
user_message = extract_message(inp)
|
|
42
|
-
chat = build_chat
|
|
43
|
-
|
|
44
|
-
# Assemble context (system prompt + history). Override #build_context to
|
|
45
|
-
# inject custom context editing logic at the Agent subclass level.
|
|
46
|
-
context = build_context(
|
|
47
|
-
inp,
|
|
48
|
-
messages: msgs,
|
|
49
|
-
thread_id: tid,
|
|
50
|
-
config: cfg,
|
|
51
|
-
budget: build_token_budget,
|
|
52
|
-
instruction: build_instructions(inp),
|
|
53
|
-
tools: self.class.tools + _handoff_tools
|
|
54
|
-
)
|
|
55
|
-
apply_instructions(chat, context[:system]) if context[:system]
|
|
56
|
-
(context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
|
|
57
|
-
context[:messages].each { |msg| chat.messages << msg }
|
|
58
|
-
|
|
59
|
-
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
60
|
-
run_before_completion_hooks!(chat, cfg)
|
|
61
|
-
|
|
62
|
-
# Register suspension hook for approval-required tools (no-op when a
|
|
63
|
-
# synchronous on_approval_required handler is already registered).
|
|
64
|
-
_register_suspension_hook!(chat)
|
|
65
|
-
|
|
66
|
-
# Check for cancellation immediately before the LLM call.
|
|
67
|
-
check_cancellation!(cfg, "invocation cancelled before LLM call")
|
|
68
|
-
|
|
69
|
-
# Forward the cancellation token to ParallelToolChat explicitly
|
|
70
|
-
# via the chat instance so that tool dispatch batches can observe
|
|
71
|
-
# cancellation without needing Thread.current.
|
|
72
|
-
chat.cancellation_token = cfg[:cancellation_token] if chat.respond_to?(:cancellation_token=)
|
|
73
|
-
|
|
74
|
-
begin
|
|
75
|
-
# Route the LLM call through the configured LLMAdapter so that the
|
|
76
|
-
# blocking HTTP request runs inside BlockingAdapterPool and the
|
|
77
|
-
# adapter can be swapped without changing agent code.
|
|
78
|
-
adapter = Phronomy.configuration.llm_adapter
|
|
79
|
-
response = adapter.complete_async(chat, user_message, config: cfg).await
|
|
80
|
-
rescue SuspendSignal => signal
|
|
81
|
-
checkpoint = Checkpoint.new(
|
|
82
|
-
thread_id: tid,
|
|
83
|
-
original_input: inp,
|
|
84
|
-
messages: chat.messages.dup,
|
|
85
|
-
pending_tool_name: signal.tool_name,
|
|
86
|
-
pending_tool_args: signal.args,
|
|
87
|
-
pending_tool_call_id: signal.tool_call_id
|
|
88
|
-
)
|
|
89
|
-
suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
|
|
90
|
-
next [suspended_result, nil]
|
|
91
|
-
ensure
|
|
92
|
-
# Clear the chat's cancellation token reference after each LLM call.
|
|
93
|
-
chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
output = response.content
|
|
97
|
-
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
98
|
-
|
|
99
|
-
# Run output guardrails before returning to the caller.
|
|
100
|
-
run_output_guardrails!(output)
|
|
101
|
-
|
|
102
|
-
result = {output: output, messages: chat.messages, usage: usage}
|
|
103
|
-
[result, usage]
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
end
|