phronomy 0.13.0 → 0.15.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 +155 -0
- data/README.md +266 -38
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/docs/mcp-client.md +75 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +471 -0
- data/lib/phronomy/agent/base.rb +509 -420
- data/lib/phronomy/agent/context/capability/base.rb +57 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +58 -53
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +507 -303
- data/lib/phronomy/engine/fsm_session.rb +181 -140
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +6 -3
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +410 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +55 -104
- data/lib/phronomy/workflow_runner.rb +239 -291
- data/lib/phronomy.rb +30 -23
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +63 -11
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -346
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
|
@@ -4,41 +4,20 @@ require "state_machines"
|
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
6
|
class Workflow
|
|
7
|
-
# Builds the anonymous state-machine Class used by
|
|
8
|
-
# workflow phase transitions.
|
|
7
|
+
# Builds the anonymous state-machine Class used by WorkflowRunner.
|
|
9
8
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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.
|
|
9
|
+
# This class compiles Workflow topology into state_machines declarations.
|
|
10
|
+
# It intentionally does not await Tasks, register completion callbacks,
|
|
11
|
+
# cancel external work, or interpret application event payloads.
|
|
18
12
|
#
|
|
19
13
|
# @api private
|
|
20
14
|
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
15
|
def initialize(
|
|
36
16
|
entry_point:,
|
|
37
17
|
declared_states:,
|
|
38
18
|
wait_state_names:,
|
|
39
19
|
external_events:,
|
|
40
20
|
entry_actions:,
|
|
41
|
-
action_timeouts:,
|
|
42
21
|
auto_transitions:,
|
|
43
22
|
exit_actions:
|
|
44
23
|
)
|
|
@@ -47,211 +26,179 @@ module Phronomy
|
|
|
47
26
|
@wait_state_names = wait_state_names
|
|
48
27
|
@external_events = external_events
|
|
49
28
|
@entry_actions = entry_actions
|
|
50
|
-
@action_timeouts = action_timeouts
|
|
51
29
|
@auto_transitions = auto_transitions
|
|
52
30
|
@exit_actions = exit_actions
|
|
53
31
|
end
|
|
54
32
|
|
|
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
33
|
def build
|
|
61
34
|
entry = @entry_point
|
|
62
35
|
all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
36
|
+
auto_transitions = @auto_transitions
|
|
37
|
+
external_events = @external_events
|
|
38
|
+
entry_actions = @entry_actions
|
|
39
|
+
exit_actions = @exit_actions
|
|
40
|
+
condition_builder = method(:build_transition_condition)
|
|
41
|
+
entry_callback_builder = method(:build_entry_callback)
|
|
42
|
+
exit_callback_builder = method(:build_exit_callback)
|
|
43
|
+
transition_callback = build_transition_action_callback
|
|
69
44
|
|
|
70
45
|
Class.new do
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
attr_accessor :async_pending
|
|
46
|
+
attr_accessor(
|
|
47
|
+
:context,
|
|
48
|
+
:current_event,
|
|
49
|
+
:selected_transition_action,
|
|
50
|
+
:selected_transition_metadata
|
|
51
|
+
)
|
|
78
52
|
|
|
79
53
|
state_machine :phase, initial: entry do
|
|
80
|
-
all_states.each { |
|
|
54
|
+
all_states.each { |state_name| state state_name }
|
|
81
55
|
|
|
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
56
|
event :state_completed do
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
transition t[:from] => t[:to]
|
|
92
|
-
end
|
|
57
|
+
auto_transitions.each do |transition_definition|
|
|
58
|
+
transition(
|
|
59
|
+
transition_definition[:from] => transition_definition[:to],
|
|
60
|
+
:if => condition_builder.call(transition_definition)
|
|
61
|
+
)
|
|
93
62
|
end
|
|
94
63
|
end
|
|
95
64
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
else
|
|
104
|
-
transition t[:from] => t[:to]
|
|
105
|
-
end
|
|
65
|
+
external_events.each do |event_name, transitions|
|
|
66
|
+
event event_name do
|
|
67
|
+
transitions.each do |transition_definition|
|
|
68
|
+
transition(
|
|
69
|
+
transition_definition[:from] => transition_definition[:to],
|
|
70
|
+
:if => condition_builder.call(transition_definition)
|
|
71
|
+
)
|
|
106
72
|
end
|
|
107
73
|
end
|
|
108
74
|
end
|
|
109
75
|
|
|
110
|
-
|
|
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|
|
|
76
|
+
entry_actions.each do |state_name, callables|
|
|
116
77
|
callables.each do |callable|
|
|
117
|
-
|
|
118
|
-
|
|
78
|
+
after_transition(
|
|
79
|
+
to: state_name,
|
|
80
|
+
&entry_callback_builder.call(callable, state_name)
|
|
81
|
+
)
|
|
119
82
|
end
|
|
120
83
|
end
|
|
121
84
|
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
|
|
85
|
+
# Both source exit callbacks and the transition action are
|
|
86
|
+
# before_transition callbacks. Register exits first to preserve the
|
|
87
|
+
# required exit -> transition action -> entry ordering.
|
|
88
|
+
exit_actions.each do |state_name, callables|
|
|
126
89
|
callables.each do |callable|
|
|
127
|
-
before_transition
|
|
128
|
-
|
|
129
|
-
|
|
90
|
+
before_transition(
|
|
91
|
+
from: state_name,
|
|
92
|
+
&exit_callback_builder.call(callable, state_name)
|
|
93
|
+
)
|
|
130
94
|
end
|
|
131
95
|
end
|
|
96
|
+
|
|
97
|
+
before_transition(&transition_callback)
|
|
132
98
|
end
|
|
133
99
|
end
|
|
134
|
-
rescue =>
|
|
135
|
-
raise ArgumentError, "Failed to build phase machine: #{
|
|
100
|
+
rescue => error
|
|
101
|
+
raise ArgumentError, "Failed to build phase machine: #{error.message}"
|
|
136
102
|
end
|
|
137
103
|
|
|
138
104
|
private
|
|
139
105
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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)
|
|
106
|
+
def build_transition_condition(transition_definition)
|
|
107
|
+
guard = transition_definition[:guard]
|
|
108
|
+
action = transition_definition[:action]
|
|
109
|
+
metadata = {
|
|
110
|
+
from: transition_definition[:from],
|
|
111
|
+
event: transition_definition[:event],
|
|
112
|
+
to: public_destination(transition_definition[:to])
|
|
113
|
+
}.freeze
|
|
114
|
+
|
|
154
115
|
->(machine) {
|
|
155
|
-
|
|
156
|
-
|
|
116
|
+
matched =
|
|
117
|
+
guard.nil? ||
|
|
118
|
+
call_with_optional_event(
|
|
119
|
+
guard,
|
|
120
|
+
machine.context,
|
|
121
|
+
machine.current_event
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if matched
|
|
125
|
+
machine.selected_transition_action = action
|
|
126
|
+
machine.selected_transition_metadata = metadata
|
|
127
|
+
end
|
|
128
|
+
matched
|
|
157
129
|
}
|
|
158
130
|
end
|
|
159
131
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
132
|
+
def build_transition_action_callback
|
|
133
|
+
->(machine) {
|
|
134
|
+
callable = machine.selected_transition_action
|
|
135
|
+
unless callable.nil?
|
|
136
|
+
metadata = machine.selected_transition_metadata || {}
|
|
137
|
+
result = call_with_optional_event(
|
|
138
|
+
callable,
|
|
139
|
+
machine.context,
|
|
140
|
+
machine.current_event
|
|
141
|
+
)
|
|
142
|
+
if result.is_a?(Phronomy::Task)
|
|
143
|
+
raise Phronomy::InvalidAsyncTransitionActionError,
|
|
144
|
+
transition_task_error_message(metadata)
|
|
145
|
+
end
|
|
146
|
+
machine.context = result if workflow_context_result?(result)
|
|
147
|
+
end
|
|
148
|
+
}
|
|
177
149
|
end
|
|
178
150
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# @param result [Phronomy::Task]
|
|
188
|
-
# @param state_name [Symbol]
|
|
189
|
-
# @param timeout_secs [Numeric, nil]
|
|
190
|
-
# @api private
|
|
191
|
-
def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
|
|
192
|
-
machine.async_pending = true
|
|
193
|
-
thread_id = machine.context.thread_id
|
|
194
|
-
if timeout_secs
|
|
195
|
-
Phronomy::Runtime.instance.timer_queue.schedule(seconds: timeout_secs) do
|
|
196
|
-
next if result.done?
|
|
197
|
-
|
|
198
|
-
Phronomy::EventLoop.instance.post(
|
|
199
|
-
Phronomy::Event.new(
|
|
200
|
-
type: :error,
|
|
201
|
-
target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
|
|
202
|
-
payload: {session_id: thread_id, result: Phronomy::ActionTimeoutError.new(
|
|
203
|
-
"Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
|
|
204
|
-
)}
|
|
205
|
-
)
|
|
206
|
-
)
|
|
151
|
+
def build_entry_callback(callable, state_name)
|
|
152
|
+
->(machine) {
|
|
153
|
+
result = callable.call(machine.context)
|
|
154
|
+
if result.is_a?(Phronomy::Task)
|
|
155
|
+
raise Phronomy::InvalidAsyncEntryActionError,
|
|
156
|
+
"Entry action for state #{state_name.inspect} returned Phronomy::Task. " \
|
|
157
|
+
"Start the asynchronous operation, register its callback/listener, " \
|
|
158
|
+
"and return the WorkflowContext or nil."
|
|
207
159
|
end
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
160
|
+
machine.context = result if workflow_context_result?(result)
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_exit_callback(callable, state_name)
|
|
165
|
+
->(machine) {
|
|
166
|
+
result = callable.call(machine.context)
|
|
167
|
+
if result.is_a?(Phronomy::Task)
|
|
168
|
+
raise Phronomy::InvalidAsyncEntryActionError,
|
|
169
|
+
"Exit action for state #{state_name.inspect} returned Phronomy::Task. " \
|
|
170
|
+
"Exit actions are synchronous Run-to-Completion callbacks."
|
|
215
171
|
end
|
|
216
|
-
|
|
217
|
-
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def call_with_optional_event(callable, context, event)
|
|
176
|
+
parameters =
|
|
177
|
+
if callable.respond_to?(:parameters)
|
|
178
|
+
callable.parameters
|
|
218
179
|
else
|
|
219
|
-
|
|
180
|
+
callable.method(:call).parameters
|
|
220
181
|
end
|
|
221
|
-
|
|
222
|
-
|
|
182
|
+
accepts_event = parameters.length >= 2
|
|
183
|
+
accepts_event ? callable.call(context, event) : callable.call(context)
|
|
223
184
|
end
|
|
224
185
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# Blocks the current execution context until the task completes or the
|
|
228
|
-
# optional timeout elapses.
|
|
229
|
-
#
|
|
230
|
-
# @param machine [Object] phase machine instance
|
|
231
|
-
# @param result [Phronomy::Task]
|
|
232
|
-
# @param state_name [Symbol]
|
|
233
|
-
# @param timeout_secs [Numeric, nil]
|
|
234
|
-
# @api private
|
|
235
|
-
def await_task_blocking(machine, result, state_name, timeout_secs)
|
|
236
|
-
enforce_timeout!(result, state_name, timeout_secs)
|
|
237
|
-
task_result = result.wait_result
|
|
238
|
-
machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
|
|
186
|
+
def workflow_context_result?(result)
|
|
187
|
+
result.respond_to?(:set_graph_metadata)
|
|
239
188
|
end
|
|
240
189
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
# @param timeout_secs [Numeric, nil]
|
|
247
|
-
# @api private
|
|
248
|
-
def enforce_timeout!(result, state_name, timeout_secs)
|
|
249
|
-
return unless timeout_secs
|
|
250
|
-
return unless result.join(timeout_secs).nil?
|
|
190
|
+
def public_destination(destination)
|
|
191
|
+
return :__finish__ if destination == Phronomy::WorkflowRunner::FINISH
|
|
192
|
+
|
|
193
|
+
destination
|
|
194
|
+
end
|
|
251
195
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
"
|
|
196
|
+
def transition_task_error_message(metadata)
|
|
197
|
+
"Transition action " \
|
|
198
|
+
"#{metadata[:from].inspect} --#{metadata[:event].inspect}--> " \
|
|
199
|
+
"#{metadata[:to].inspect} returned Phronomy::Task. " \
|
|
200
|
+
"Start the asynchronous operation, register its callback/listener, " \
|
|
201
|
+
"and return the WorkflowContext or nil."
|
|
255
202
|
end
|
|
256
203
|
end
|
|
257
204
|
end
|