phronomy 0.14.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 +65 -0
- data/README.md +236 -57
- 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/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -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 +500 -411
- data/lib/phronomy/agent/context/capability/base.rb +51 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
- 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 +42 -6
- data/lib/phronomy/engine/event_loop.rb +269 -112
- data/lib/phronomy/engine/fsm_session.rb +180 -142
- 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 +2 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/tools/mcp.rb +25 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +54 -102
- data/lib/phronomy/workflow_runner.rb +238 -300
- data/lib/phronomy.rb +6 -4
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +18 -7
- 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 -352
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
|
@@ -1,214 +1,275 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
-
require "state_machines"
|
|
5
4
|
|
|
6
5
|
module Phronomy
|
|
7
|
-
# Execution
|
|
8
|
-
# Manages state entry/exit action execution, phase transitions, halt/resume, and wait states.
|
|
9
|
-
# Instantiated by Phronomy::Workflow and used internally.
|
|
6
|
+
# Execution boundary for compiled Workflows.
|
|
10
7
|
#
|
|
11
|
-
#
|
|
8
|
+
# WorkflowRunner prepares WorkflowContext instances, registers FSMSession
|
|
9
|
+
# objects with the Runtime-owned EventLoop, observes completion, and persists
|
|
10
|
+
# serializable Workflow snapshots. All Workflow execution APIs share this
|
|
11
|
+
# path; their only differences are blocking and observation semantics.
|
|
12
12
|
#
|
|
13
|
-
# State transitions are driven entirely by state_machines. The PhaseTracker
|
|
14
|
-
# holds a reference to the current WorkflowContext via +attr_accessor :context+,
|
|
15
|
-
# and guard lambdas evaluate +m.context+ (the WorkflowContext) rather than
|
|
16
|
-
# the PhaseTracker itself. This ensures that "what happens next" is always
|
|
17
|
-
# determined by the declared state machine topology, never by Phronomy internals.
|
|
18
|
-
#
|
|
19
|
-
# Entry and exit actions are registered as state_machines +after_transition to:+
|
|
20
|
-
# and +before_transition from:+ callbacks respectively. Entry actions may either
|
|
21
|
-
# mutate the context in place or return a new context (e.g. via +s.merge(...)+).
|
|
22
|
-
# When an entry action returns a Phronomy::WorkflowContext, that value replaces
|
|
23
|
-
# the current context; otherwise the return value is ignored.
|
|
24
|
-
# Exit actions are always mutation-in-place; their return value is ignored.
|
|
25
|
-
#
|
|
26
|
-
# The sole exception is the initial state: state_machines does not fire transition
|
|
27
|
-
# callbacks on initialization, so the entry action for the entry point is invoked
|
|
28
|
-
# directly by WorkflowRunner before the main execution loop begins.
|
|
29
|
-
#
|
|
30
|
-
# == Two transition categories registered in PhaseTracker
|
|
31
|
-
#
|
|
32
|
-
# 1. state_completed — all auto-fire transitions (with or without guards).
|
|
33
|
-
# Fired when an action state's action completes.
|
|
34
|
-
# Guards are evaluated in declaration order; first match wins.
|
|
35
|
-
# (declared with +transition from: :foo, to: :bar+ or
|
|
36
|
-
# +transition from: :foo, guard: ..., to: :bar+)
|
|
37
|
-
#
|
|
38
|
-
# 2. <event_name> — external events triggered by human input, originating
|
|
39
|
-
# from wait states
|
|
40
|
-
# (declared with +transition from: :awaiting, on: :approve, to: :run+)
|
|
41
13
|
# @api private
|
|
42
14
|
class WorkflowRunner
|
|
43
15
|
include Phronomy::Runnable
|
|
44
16
|
|
|
45
|
-
# Sentinel value for the terminal state of a workflow.
|
|
46
17
|
FINISH = :__end__
|
|
47
18
|
|
|
48
|
-
|
|
19
|
+
Execution = Data.define(
|
|
20
|
+
:context,
|
|
21
|
+
:thread_id,
|
|
22
|
+
:recursion_limit,
|
|
23
|
+
:store,
|
|
24
|
+
:persist
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def initialize(
|
|
28
|
+
state_class:,
|
|
29
|
+
entry_actions:,
|
|
30
|
+
declared_states:,
|
|
31
|
+
auto_transitions:,
|
|
32
|
+
external_events:,
|
|
33
|
+
entry_point:,
|
|
34
|
+
exit_actions: {},
|
|
35
|
+
wait_state_names: [],
|
|
36
|
+
state_store: nil
|
|
37
|
+
)
|
|
49
38
|
@state_class = state_class
|
|
50
|
-
@entry_actions = entry_actions
|
|
39
|
+
@entry_actions = entry_actions
|
|
51
40
|
@declared_states = declared_states
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
@auto_state_set = auto_transitions.each_with_object({}) do |transition, set|
|
|
42
|
+
set[transition[:from]] = true
|
|
43
|
+
end
|
|
44
|
+
@external_events = external_events
|
|
55
45
|
@entry_point = entry_point
|
|
56
46
|
@wait_state_names = wait_state_names
|
|
57
47
|
@state_store = state_store
|
|
58
|
-
@action_timeouts = action_timeouts # { state_name => seconds }
|
|
59
48
|
@phase_machine_class = Workflow::PhaseMachineBuilder.new(
|
|
60
49
|
entry_point: @entry_point,
|
|
61
50
|
declared_states: @declared_states,
|
|
62
51
|
wait_state_names: @wait_state_names,
|
|
63
52
|
external_events: @external_events,
|
|
64
53
|
entry_actions: @entry_actions,
|
|
65
|
-
action_timeouts: @action_timeouts,
|
|
66
54
|
auto_transitions: auto_transitions,
|
|
67
55
|
exit_actions: exit_actions
|
|
68
56
|
).build
|
|
69
57
|
end
|
|
70
58
|
|
|
71
|
-
# Executes the workflow from the initial state.
|
|
72
|
-
# @param input [Hash] initial context field values
|
|
73
|
-
# @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id:, state_store: }
|
|
74
|
-
# @return [Object] final context (includes Phronomy::WorkflowContext)
|
|
75
|
-
# @api private
|
|
76
59
|
def invoke(input, config: {})
|
|
60
|
+
ensure_blocking_call_allowed!(:invoke, :invoke_async)
|
|
77
61
|
caller_meta = {}
|
|
78
62
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
79
63
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
80
64
|
|
|
81
65
|
trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
|
|
82
|
-
|
|
83
|
-
result =
|
|
84
|
-
store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
|
|
66
|
+
execution = prepare_new_execution(input, config)
|
|
67
|
+
result = start_execution(execution).wait_result
|
|
85
68
|
[result, nil]
|
|
86
69
|
end
|
|
87
70
|
end
|
|
88
71
|
|
|
89
|
-
# Registers the workflow with the EventLoop and returns a {Phronomy::Task}
|
|
90
|
-
# immediately without blocking the caller. The task resolves with the final
|
|
91
|
-
# context when the workflow finishes.
|
|
92
|
-
#
|
|
93
|
-
# This is the EventLoop-driven equivalent of spawning a thread around
|
|
94
|
-
# {#invoke}. No extra OS thread is created; the EventLoop's existing thread
|
|
95
|
-
# drives the execution.
|
|
96
|
-
#
|
|
97
|
-
# @param input [Hash] initial context field values
|
|
98
|
-
# @param config [Hash]
|
|
99
|
-
# @return [Phronomy::Task]
|
|
100
|
-
# @api private
|
|
101
72
|
def invoke_deferred(input, config: {})
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if error
|
|
118
|
-
result_task.backend.unblock(nil, error)
|
|
119
|
-
result_task.transition!(:failed, error: error)
|
|
120
|
-
else
|
|
121
|
-
result_task.backend.unblock(result, nil)
|
|
122
|
-
result_task.transition!(:completed, value: result)
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
else
|
|
126
|
-
event_loop.register(session, completion: result_task)
|
|
127
|
-
end
|
|
128
|
-
result_task
|
|
73
|
+
execution = prepare_new_execution(input, config)
|
|
74
|
+
start_execution(execution)
|
|
75
|
+
rescue => error
|
|
76
|
+
failed_task("workflow-async:preparation", error)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def stream(input, config: {}, &observer)
|
|
80
|
+
ensure_blocking_call_allowed!(:stream, :invoke_async)
|
|
81
|
+
raise ArgumentError, "stream requires a block" unless observer
|
|
82
|
+
|
|
83
|
+
execution = prepare_new_execution(input, config)
|
|
84
|
+
start_execution(
|
|
85
|
+
execution,
|
|
86
|
+
stable_observer: observer
|
|
87
|
+
).wait_result
|
|
129
88
|
end
|
|
130
89
|
|
|
131
|
-
# Generic resume. Equivalent to +send_event(state:, event: :resume, input:)+.
|
|
132
|
-
# @param state [Object] halted context
|
|
133
|
-
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
134
|
-
# @return [Object] final context
|
|
135
|
-
# @api private
|
|
136
90
|
def resume(state:, input: nil)
|
|
137
91
|
send_event(state: state, event: :resume, input: input)
|
|
138
92
|
end
|
|
139
93
|
|
|
140
|
-
# Fires a named event to advance a halted workflow.
|
|
141
|
-
#
|
|
142
|
-
# The special event +:resume+ selects the first external event registered
|
|
143
|
-
# for the current wait state and fires it.
|
|
144
|
-
#
|
|
145
|
-
# @param state [Object] halted context
|
|
146
|
-
# @param event [Symbol] named event or +:resume+ for generic resumption
|
|
147
|
-
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
148
|
-
# @return [Object] final context
|
|
149
|
-
# @api private
|
|
150
94
|
def send_event(state:, event:, input: nil)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
current_phase =
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
unless name
|
|
159
|
-
raise ArgumentError,
|
|
160
|
-
"No external event registered for wait state #{current_phase.inspect}"
|
|
161
|
-
end
|
|
162
|
-
name
|
|
163
|
-
else
|
|
164
|
-
unless @external_events.key?(event)
|
|
165
|
-
raise ArgumentError,
|
|
166
|
-
"Unknown event #{event.inspect}. Valid events: #{@external_events.keys.inspect}"
|
|
167
|
-
end
|
|
168
|
-
event
|
|
95
|
+
ensure_blocking_call_allowed!(:send_event, :signal)
|
|
96
|
+
context = input ? state.merge(input) : state
|
|
97
|
+
current_phase = context.phase.to_sym
|
|
98
|
+
event_name = resolve_resume_event(current_phase, event)
|
|
99
|
+
thread_id = context.thread_id
|
|
100
|
+
unless thread_id
|
|
101
|
+
raise ArgumentError, "Halted WorkflowContext has no thread_id"
|
|
169
102
|
end
|
|
170
103
|
|
|
171
|
-
|
|
104
|
+
execution = Execution.new(
|
|
105
|
+
context: context,
|
|
106
|
+
thread_id: thread_id.to_s,
|
|
172
107
|
recursion_limit: Phronomy.configuration.recursion_limit,
|
|
173
|
-
|
|
108
|
+
store: configured_store,
|
|
109
|
+
persist: true
|
|
110
|
+
)
|
|
111
|
+
start_execution(
|
|
112
|
+
execution,
|
|
113
|
+
resume_event: event_name,
|
|
114
|
+
resume_phase: current_phase
|
|
115
|
+
).wait_result
|
|
174
116
|
end
|
|
175
117
|
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
def
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
118
|
+
# Posts an application-defined event to a currently live Workflow session.
|
|
119
|
+
#
|
|
120
|
+
# Admission is asynchronous. A true result means the EventLoop accepted the
|
|
121
|
+
# event for an admitted session; it does not mean that a transition matched.
|
|
122
|
+
# A false result means that the Runtime is stopping or the session is no
|
|
123
|
+
# longer admitted.
|
|
124
|
+
def signal(thread_id:, event:, payload: nil)
|
|
125
|
+
if thread_id.nil?
|
|
126
|
+
raise ArgumentError, "thread_id is required"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
event_name = event.to_sym
|
|
130
|
+
unless @external_events.key?(event_name)
|
|
131
|
+
raise ArgumentError,
|
|
132
|
+
"Unknown event #{event_name.inspect}. " \
|
|
133
|
+
"Valid events: #{@external_events.keys.inspect}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
Phronomy::Runtime.instance.event_loop.post_to_session(
|
|
137
|
+
Phronomy::Event.new(
|
|
138
|
+
type: event_name,
|
|
139
|
+
target_id: thread_id.to_s,
|
|
140
|
+
payload: payload
|
|
141
|
+
)
|
|
142
|
+
)
|
|
188
143
|
end
|
|
189
144
|
|
|
190
145
|
private
|
|
191
146
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
147
|
+
def ensure_blocking_call_allowed!(method_name, async_alternative)
|
|
148
|
+
return unless Phronomy::Runtime.instance.event_loop.current?
|
|
149
|
+
|
|
150
|
+
raise Phronomy::Error,
|
|
151
|
+
"Cannot call Workflow##{method_name} from the EventLoop thread. " \
|
|
152
|
+
"Use #{async_alternative} instead."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def prepare_new_execution(input, config)
|
|
156
|
+
thread_id = (config[:thread_id] || SecureRandom.uuid).to_s
|
|
157
|
+
recursion_limit = config.fetch(
|
|
158
|
+
:recursion_limit,
|
|
159
|
+
Phronomy.configuration.recursion_limit
|
|
160
|
+
)
|
|
161
|
+
store = configured_store(config)
|
|
162
|
+
snapshot = store&.load(thread_id) if config[:thread_id]
|
|
163
|
+
|
|
164
|
+
stored_fields = snapshot && snapshot[:fields]
|
|
165
|
+
initial_fields = if stored_fields
|
|
166
|
+
stored_fields
|
|
167
|
+
.transform_keys(&:to_sym)
|
|
168
|
+
.merge(input.transform_keys(&:to_sym))
|
|
201
169
|
else
|
|
202
170
|
input
|
|
203
171
|
end
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
172
|
+
|
|
173
|
+
context = @state_class.new(**initial_fields)
|
|
174
|
+
context.set_graph_metadata(thread_id: thread_id)
|
|
175
|
+
|
|
176
|
+
Execution.new(
|
|
177
|
+
context: context,
|
|
178
|
+
thread_id: thread_id,
|
|
179
|
+
recursion_limit: recursion_limit,
|
|
180
|
+
store: store,
|
|
181
|
+
persist: !config[:thread_id].nil?
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def configured_store(config = {})
|
|
186
|
+
config.fetch(:state_store, @state_store) ||
|
|
187
|
+
Phronomy.configuration.state_store
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def start_execution(
|
|
191
|
+
execution,
|
|
192
|
+
resume_event: nil,
|
|
193
|
+
resume_phase: nil,
|
|
194
|
+
stable_observer: nil
|
|
195
|
+
)
|
|
196
|
+
runtime = Phronomy::Runtime.instance
|
|
197
|
+
result_task = Phronomy::Task.deferred(
|
|
198
|
+
name: "workflow:#{execution.thread_id}"
|
|
199
|
+
)
|
|
200
|
+
source_task = Phronomy::Task.deferred(
|
|
201
|
+
name: "workflow-source:#{execution.thread_id}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
source_task.on_complete do |result, error|
|
|
205
|
+
finalize_execution(
|
|
206
|
+
result_task: result_task,
|
|
207
|
+
result: result,
|
|
208
|
+
error: error,
|
|
209
|
+
store: execution.store,
|
|
210
|
+
thread_id: execution.thread_id,
|
|
211
|
+
persist: execution.persist
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
session = build_session_for(
|
|
216
|
+
context: execution.context,
|
|
217
|
+
recursion_limit: execution.recursion_limit,
|
|
218
|
+
runtime: runtime,
|
|
219
|
+
resume_event: resume_event,
|
|
220
|
+
resume_phase: resume_phase,
|
|
221
|
+
stable_observer: stable_observer
|
|
222
|
+
)
|
|
223
|
+
runtime.event_loop.register(session, completion: source_task)
|
|
224
|
+
result_task
|
|
225
|
+
rescue => error
|
|
226
|
+
fail_task(result_task, error) if result_task
|
|
227
|
+
result_task || failed_task("workflow:registration", error)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def finalize_execution(
|
|
231
|
+
result_task:,
|
|
232
|
+
result:,
|
|
233
|
+
error:,
|
|
234
|
+
store:,
|
|
235
|
+
thread_id:,
|
|
236
|
+
persist:
|
|
237
|
+
)
|
|
238
|
+
if error
|
|
239
|
+
fail_task(result_task, error)
|
|
240
|
+
return
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
begin
|
|
244
|
+
persist_snapshot(store, thread_id, result, persist: persist)
|
|
245
|
+
rescue => persistence_error
|
|
246
|
+
fail_task(result_task, persistence_error)
|
|
247
|
+
return
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
complete_task(result_task, result)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def persist_snapshot(store, thread_id, context, persist:)
|
|
254
|
+
return unless store && persist
|
|
255
|
+
|
|
256
|
+
store.save(
|
|
257
|
+
thread_id,
|
|
258
|
+
{
|
|
259
|
+
fields: context.to_h,
|
|
260
|
+
phase: context.phase.to_s
|
|
261
|
+
}
|
|
262
|
+
)
|
|
207
263
|
end
|
|
208
264
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
265
|
+
def build_session_for(
|
|
266
|
+
context:,
|
|
267
|
+
recursion_limit:,
|
|
268
|
+
runtime:,
|
|
269
|
+
resume_event: nil,
|
|
270
|
+
resume_phase: nil,
|
|
271
|
+
stable_observer: nil
|
|
272
|
+
)
|
|
212
273
|
Phronomy::FSMSession.new(
|
|
213
274
|
id: context.thread_id,
|
|
214
275
|
context: context,
|
|
@@ -221,171 +282,48 @@ module Phronomy
|
|
|
221
282
|
phase_machine_class: @phase_machine_class,
|
|
222
283
|
recursion_limit: recursion_limit,
|
|
223
284
|
event_loop: runtime.event_loop,
|
|
224
|
-
timer_queue_provider: -> { runtime.timer_queue },
|
|
225
|
-
action_timeouts: @action_timeouts,
|
|
226
285
|
resume_event: resume_event,
|
|
227
|
-
resume_phase: resume_phase
|
|
286
|
+
resume_phase: resume_phase,
|
|
287
|
+
stable_observer: stable_observer
|
|
228
288
|
)
|
|
229
289
|
end
|
|
230
290
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
context: context,
|
|
239
|
-
recursion_limit: recursion_limit,
|
|
240
|
-
runtime: runtime,
|
|
241
|
-
resume_event: resume_event,
|
|
242
|
-
resume_phase: resume_phase
|
|
243
|
-
)
|
|
244
|
-
completion_queue = event_loop.register(session)
|
|
245
|
-
result = completion_queue.pop
|
|
246
|
-
raise result if result.is_a?(Exception)
|
|
247
|
-
result
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def run_workflow(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
|
|
251
|
-
# Mark the current thread as a synchronous execution context.
|
|
252
|
-
# This allows WorkflowContext field mutations via setters without raising
|
|
253
|
-
# WorkflowContextOwnershipError (which would otherwise fire since EventLoop
|
|
254
|
-
# is always active and run_workflow runs on the caller's thread).
|
|
255
|
-
Thread.current[:phronomy_sync_execution] = Thread.current[:phronomy_sync_execution].to_i + 1
|
|
256
|
-
_run_workflow_body(ctx, resume_event: resume_event, resume_phase: resume_phase,
|
|
257
|
-
recursion_limit: recursion_limit, &event_block)
|
|
258
|
-
ensure
|
|
259
|
-
depth = Thread.current[:phronomy_sync_execution].to_i - 1
|
|
260
|
-
Thread.current[:phronomy_sync_execution] = (depth > 0) ? depth : nil
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
def _run_workflow_body(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
|
|
264
|
-
if resume_event
|
|
265
|
-
# -- Resume from a wait state -------------------------------------------
|
|
266
|
-
# Fire the external event on a tracker positioned at the wait state.
|
|
267
|
-
# state_machines will invoke before_transition (exit) and after_transition
|
|
268
|
-
# (entry) callbacks as part of the transition, so both actions fire here.
|
|
269
|
-
current_state = resume_phase
|
|
270
|
-
tracker = new_phase_machine(current_state)
|
|
271
|
-
tracker.context = ctx
|
|
272
|
-
fire_event!(tracker, resume_event, current_state)
|
|
273
|
-
ctx = tracker.context
|
|
274
|
-
next_phase = tracker.phase.to_sym
|
|
275
|
-
current_state = (next_phase == current_state) ? FINISH : next_phase
|
|
276
|
-
else
|
|
277
|
-
# -- Fresh start --------------------------------------------------------
|
|
278
|
-
current_state = @entry_point
|
|
279
|
-
tracker = new_phase_machine(current_state)
|
|
280
|
-
tracker.context = ctx
|
|
281
|
-
# state_machines only fires after_transition callbacks on transitions.
|
|
282
|
-
# The entry point has no prior transition, so we invoke its entry actions directly.
|
|
283
|
-
@entry_actions[current_state]&.each do |c|
|
|
284
|
-
result = c.call(ctx)
|
|
285
|
-
if result.is_a?(Phronomy::Task)
|
|
286
|
-
timeout_secs = @action_timeouts[current_state]
|
|
287
|
-
if timeout_secs
|
|
288
|
-
if result.join(timeout_secs).nil?
|
|
289
|
-
result.cancel!
|
|
290
|
-
raise Phronomy::ActionTimeoutError,
|
|
291
|
-
"Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
task_result = result.wait_result
|
|
295
|
-
ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
|
|
296
|
-
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
297
|
-
ctx = result
|
|
298
|
-
end
|
|
291
|
+
def resolve_resume_event(current_phase, event)
|
|
292
|
+
event_name = event.to_sym
|
|
293
|
+
unless event_name == :resume
|
|
294
|
+
unless @external_events.key?(event_name)
|
|
295
|
+
raise ArgumentError,
|
|
296
|
+
"Unknown event #{event_name.inspect}. " \
|
|
297
|
+
"Valid events: #{@external_events.keys.inspect}"
|
|
299
298
|
end
|
|
300
|
-
|
|
299
|
+
return event_name
|
|
301
300
|
end
|
|
302
301
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
event_queue = []
|
|
307
|
-
step = 0
|
|
308
|
-
|
|
309
|
-
loop do
|
|
310
|
-
break if current_state == FINISH
|
|
311
|
-
|
|
312
|
-
# -- Process next pending event -----------------------------------------
|
|
313
|
-
# Dequeue one event and fire it against the state machine. Guards are
|
|
314
|
-
# evaluated here (at fire time). Entry/exit callbacks fire inside fire_event!.
|
|
315
|
-
if (event = event_queue.shift)
|
|
316
|
-
if step >= recursion_limit
|
|
317
|
-
raise Phronomy::RecursionLimitError,
|
|
318
|
-
"Recursion limit (#{recursion_limit}) exceeded"
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
fire_event!(tracker, event, current_state)
|
|
322
|
-
ctx = tracker.context
|
|
323
|
-
next_phase = tracker.phase.to_sym
|
|
324
|
-
# When next_phase == current_state no transition matched → terminal state.
|
|
325
|
-
current_state = (next_phase == current_state) ? FINISH : next_phase
|
|
326
|
-
step += 1
|
|
327
|
-
next
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
# -- Queue empty: check for halt -----------------------------------------
|
|
331
|
-
# Auto-halt at wait states: persist phase in context and return to caller.
|
|
332
|
-
# The caller resumes via send_event.
|
|
333
|
-
if @wait_state_names.include?(current_state)
|
|
334
|
-
ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: current_state)
|
|
335
|
-
return ctx
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
# -- Validate state is known --------------------------------------------
|
|
339
|
-
unless @declared_states.include?(current_state)
|
|
340
|
-
raise ArgumentError, "State #{current_state.inspect} is not defined"
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
# -- Emit stream event and enqueue transition ---------------------------
|
|
344
|
-
# Entry action for current_state has already been invoked (either by the
|
|
345
|
-
# initial manual call above, or by the after_transition callback fired
|
|
346
|
-
# inside fire_event! on the previous iteration).
|
|
347
|
-
event_block&.call({state: current_state, context: ctx})
|
|
348
|
-
|
|
349
|
-
# state_completed: unified event for all auto-fire transitions.
|
|
350
|
-
# No enqueue: terminal state — next iteration exits via FINISH check.
|
|
351
|
-
if @auto_state_set.key?(current_state)
|
|
352
|
-
event_queue << :state_completed
|
|
353
|
-
else
|
|
354
|
-
current_state = FINISH
|
|
302
|
+
name, = @external_events.find do |_candidate, transitions|
|
|
303
|
+
Array(transitions).any? do |transition|
|
|
304
|
+
transition[:from] == current_phase
|
|
355
305
|
end
|
|
356
306
|
end
|
|
307
|
+
return name if name
|
|
357
308
|
|
|
358
|
-
|
|
359
|
-
|
|
309
|
+
raise ArgumentError,
|
|
310
|
+
"No external event registered for state #{current_phase.inspect}"
|
|
360
311
|
end
|
|
361
312
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
return if tracker.send(event_name)
|
|
313
|
+
def complete_task(task, value)
|
|
314
|
+
task.backend.unblock(value, nil)
|
|
315
|
+
task.transition!(:completed, value: value)
|
|
316
|
+
end
|
|
367
317
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
318
|
+
def fail_task(task, error)
|
|
319
|
+
task.backend.unblock(nil, error)
|
|
320
|
+
task.transition!(:failed, error: error)
|
|
371
321
|
end
|
|
372
322
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
# <external_name> — external events originating from wait states
|
|
378
|
-
# after_transition to — entry callbacks (invoked when entering a state)
|
|
379
|
-
# before_transition from — exit callbacks (invoked when leaving a state)
|
|
380
|
-
#
|
|
381
|
-
# Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
|
|
382
|
-
# Creates a PhaseTracker instance initialized to +from_state+.
|
|
383
|
-
def new_phase_machine(from_state)
|
|
384
|
-
machine = @phase_machine_class.new
|
|
385
|
-
# Override the initial state set by state_machine's initializer so we can
|
|
386
|
-
# resume from an arbitrary state (e.g. after a wait state).
|
|
387
|
-
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
388
|
-
machine
|
|
323
|
+
def failed_task(name, error)
|
|
324
|
+
task = Phronomy::Task.deferred(name: name)
|
|
325
|
+
fail_task(task, error)
|
|
326
|
+
task
|
|
389
327
|
end
|
|
390
328
|
end
|
|
391
329
|
end
|
data/lib/phronomy.rb
CHANGED
|
@@ -12,10 +12,12 @@ 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
|
# Phronomy::FSMSession is the top-level cooperative execution engine shared by
|
|
15
|
-
# WorkflowRunner and
|
|
15
|
+
# WorkflowRunner, AgentInvocationSessionBuilder, and ToolInvocationSessionBuilder.
|
|
16
16
|
loader.inflector.inflect("fsm_session" => "FSMSession")
|
|
17
17
|
# LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
|
|
18
18
|
loader.inflector.inflect("llm_adapter" => "LLMAdapter")
|
|
19
|
+
# LLMOperationResult: preserve the LLM acronym for the Agent result carrier.
|
|
20
|
+
loader.inflector.inflect("llm_operation_result" => "LLMOperationResult")
|
|
19
21
|
# LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
|
|
20
22
|
loader.inflector.inflect("ruby_llm" => "RubyLLM")
|
|
21
23
|
# Collapse engine/ so that its contents autoload directly under Phronomy::
|
|
@@ -34,7 +36,7 @@ module Phronomy
|
|
|
34
36
|
class ParseError < Error; end
|
|
35
37
|
class RecursionLimitError < Error; end
|
|
36
38
|
class ToolError < Error; end
|
|
37
|
-
#
|
|
39
|
+
# Base error for Phronomy-owned timed boundaries and generic timeout primitives.
|
|
38
40
|
class TimeoutError < Error; end
|
|
39
41
|
|
|
40
42
|
class ConfigurationError < Error; end
|
|
@@ -125,8 +127,8 @@ module Phronomy
|
|
|
125
127
|
# result is available. Extends {TimeoutError} for backwards compatibility.
|
|
126
128
|
class ScopeTimeoutError < TimeoutError; end
|
|
127
129
|
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
+
# Deprecated compatibility constant. Workflow entry/exit actions are
|
|
131
|
+
# synchronous and the Workflow DSL no longer accepts +action_timeout:+.
|
|
130
132
|
class ActionTimeoutError < TimeoutError; end
|
|
131
133
|
|
|
132
134
|
# Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
|
|
@@ -29,11 +29,14 @@ PREAMBLE = <<~RUBY
|
|
|
29
29
|
# Patch invoke methods to return canned responses instead of calling the LLM.
|
|
30
30
|
module Phronomy
|
|
31
31
|
module Agent
|
|
32
|
-
|
|
32
|
+
# Prepend overrides AsyncEventApi (which is also prepended) so invoke
|
|
33
|
+
# returns a canned response without triggering the EventLoop.
|
|
34
|
+
module CiInvokeStub
|
|
33
35
|
def invoke(input = nil, **)
|
|
34
36
|
{output: "ci-stub-output", messages: []}
|
|
35
37
|
end
|
|
36
38
|
end
|
|
39
|
+
Base.prepend(CiInvokeStub)
|
|
37
40
|
|
|
38
41
|
class Runner
|
|
39
42
|
def invoke(input = nil, **)
|