phronomy 0.11.0 → 0.12.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/README.md +17 -8
- data/benchmark/bench_agent_invoke.rb +1 -0
- data/lib/phronomy/agent/base.rb +197 -83
- data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
- data/lib/phronomy/agent/concerns/retryable.rb +6 -2
- data/lib/phronomy/agent/invocation_context.rb +171 -0
- data/lib/phronomy/agent/invocation_session.rb +346 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
- data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
- data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
- data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
- data/lib/phronomy/engine/fsm_session.rb +248 -0
- data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
- data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
- data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
- data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
- data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
- data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
- data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
- data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
- data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
- data/lib/phronomy/generator_verifier.rb +20 -11
- data/lib/phronomy/multi_agent/orchestrator.rb +6 -6
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
- data/lib/phronomy/tools/mcp.rb +17 -10
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
- data/lib/phronomy/workflow.rb +6 -3
- data/lib/phronomy/workflow_context.rb +7 -5
- data/lib/phronomy/workflow_runner.rb +77 -27
- data/lib/phronomy.rb +17 -9
- metadata +35 -35
- data/lib/phronomy/agent/checkpoint.rb +0 -208
- data/lib/phronomy/agent/checkpoint_store.rb +0 -97
- data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
- data/lib/phronomy/agent/suspend_signal.rb +0 -37
- data/lib/phronomy/context.rb +0 -12
- data/lib/phronomy/tool.rb +0 -9
- data/lib/phronomy/workflow/fsm_session.rb +0 -249
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
- /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
module Agent
|
|
7
|
-
module Concerns
|
|
8
|
-
# Adds suspend/resume and tool-approval support to an agent.
|
|
9
|
-
#
|
|
10
|
-
# Included in {Phronomy::Agent::Base}. When a tool decorated with
|
|
11
|
-
# +requires_approval true+ is called and no synchronous approval handler
|
|
12
|
-
# has been registered, the invocation is suspended and a
|
|
13
|
-
# @api private
|
|
14
|
-
# {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
|
|
15
|
-
module Suspendable
|
|
16
|
-
# Registers a callback that is invoked before executing any tool that has
|
|
17
|
-
# +requires_approval true+ set. The block receives the tool name (String)
|
|
18
|
-
# and the arguments Hash, and must return a truthy value to allow execution.
|
|
19
|
-
# Returning a falsy value causes the tool to return a denial message instead
|
|
20
|
-
# of executing.
|
|
21
|
-
#
|
|
22
|
-
# When no handler is registered and a tool with +requires_approval+ is
|
|
23
|
-
# called, #invoke returns a suspended result hash containing a
|
|
24
|
-
# {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
|
|
25
|
-
# obtaining an approval decision from the user or an external system.
|
|
26
|
-
#
|
|
27
|
-
# @example Synchronous handler
|
|
28
|
-
# agent = MyAgent.new
|
|
29
|
-
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
30
|
-
# @return [self]
|
|
31
|
-
# @api private
|
|
32
|
-
def on_approval_required(&block)
|
|
33
|
-
@approval_handler = block
|
|
34
|
-
self
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Registers a scope policy callable for this agent instance.
|
|
38
|
-
#
|
|
39
|
-
# The callable receives +(tool_class, scope, agent)+ and must return
|
|
40
|
-
# +:allow+, +:reject+, or +:approve+.
|
|
41
|
-
#
|
|
42
|
-
# @example Reject all write-scoped tools
|
|
43
|
-
# agent.scope_policy = ->(_tc, scope, _agent) { scope == :write ? :reject : :allow }
|
|
44
|
-
#
|
|
45
|
-
# @param policy [#call]
|
|
46
|
-
# @return [void]
|
|
47
|
-
# @api public
|
|
48
|
-
def scope_policy=(policy)
|
|
49
|
-
@scope_policy = policy
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Sets the idempotency store used to guard against duplicate resumes.
|
|
53
|
-
#
|
|
54
|
-
# The store must respond to:
|
|
55
|
-
# - +consumed?(checkpoint_id)+ ⇒ Boolean
|
|
56
|
-
# - +consume!(checkpoint_id)+ ⇒ void; raises {Phronomy::CheckpointAlreadyResumedError} on duplicate
|
|
57
|
-
#
|
|
58
|
-
# Defaults to a per-instance {Phronomy::Agent::CheckpointStore} (in-memory, not thread-safe).
|
|
59
|
-
# Assign a shared persistent store when resuming across processes (e.g. Redis-backed).
|
|
60
|
-
# Custom stores are responsible for ensuring thread-safety if shared across threads.
|
|
61
|
-
#
|
|
62
|
-
# @param store [#consumed?, #consume!]
|
|
63
|
-
# @return [void]
|
|
64
|
-
# @api public
|
|
65
|
-
def checkpoint_store=(store)
|
|
66
|
-
@checkpoint_store = store
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
|
|
70
|
-
#
|
|
71
|
-
# This method reconstructs the conversation state captured at suspension
|
|
72
|
-
# time, injects the tool result (executed or denied), and continues the
|
|
73
|
-
# LLM loop until it produces a final answer.
|
|
74
|
-
#
|
|
75
|
-
# @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
|
|
76
|
-
# the suspended #invoke call
|
|
77
|
-
# @param approved [Boolean] +true+ to execute the pending tool; +false+
|
|
78
|
-
# to inject a denial message and let the LLM handle it gracefully
|
|
79
|
-
# @param config [Hash] same runtime options as #invoke
|
|
80
|
-
# @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
|
|
81
|
-
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint, messages: Array }+
|
|
82
|
-
# when a second approval-required tool is encountered during continuation
|
|
83
|
-
# @raise [Phronomy::FilterBlockError] when an output filter rejects the value
|
|
84
|
-
# @raise [Phronomy::CheckpointAlreadyResumedError] when the checkpoint has already been consumed
|
|
85
|
-
# @api private
|
|
86
|
-
def resume(checkpoint, approved:, config: {})
|
|
87
|
-
# Guard against duplicate resumes using the idempotency store.
|
|
88
|
-
_checkpoint_store.consume!(checkpoint.checkpoint_id)
|
|
89
|
-
# Build a fresh chat with all tools registered.
|
|
90
|
-
chat = build_chat
|
|
91
|
-
|
|
92
|
-
# Re-apply system instructions and register tools so the LLM has the
|
|
93
|
-
# same persona/context as the original invocation. build_context
|
|
94
|
-
# includes all tool classes (static + handoff) via add_capability.
|
|
95
|
-
context = build_context(checkpoint.original_input, messages: [])
|
|
96
|
-
apply_instructions(chat, context[:system]) if context[:system]
|
|
97
|
-
(context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
|
|
98
|
-
|
|
99
|
-
# Restore the full conversation (history + user + assistant with tool call).
|
|
100
|
-
checkpoint.messages.each { |msg| chat.messages << msg }
|
|
101
|
-
|
|
102
|
-
# Determine the tool result: execute it or inject a denial string.
|
|
103
|
-
tool_result =
|
|
104
|
-
if approved
|
|
105
|
-
tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
|
|
106
|
-
tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
|
|
107
|
-
else
|
|
108
|
-
"Tool execution denied."
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Inject the tool result so the LLM can continue.
|
|
112
|
-
chat.add_message(
|
|
113
|
-
role: :tool,
|
|
114
|
-
content: tool_result.to_s,
|
|
115
|
-
tool_call_id: checkpoint.pending_tool_call_id
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
# Re-register the suspension hook so that any further requires_approval
|
|
119
|
-
# tools encountered during continuation are intercepted rather than
|
|
120
|
-
# executed without approval (cascading / chained approval scenario).
|
|
121
|
-
_register_suspension_hook!(chat)
|
|
122
|
-
|
|
123
|
-
# Continue the LLM loop. Rescue SuspendSignal so that a second
|
|
124
|
-
# approval-required tool produces a new checkpoint instead of running
|
|
125
|
-
# without consent.
|
|
126
|
-
begin
|
|
127
|
-
response = chat.complete
|
|
128
|
-
rescue SuspendSignal => signal
|
|
129
|
-
new_checkpoint = Checkpoint.new(
|
|
130
|
-
checkpoint_id: SecureRandom.uuid,
|
|
131
|
-
agent_class: self.class.name,
|
|
132
|
-
requested_at: Time.now.utc,
|
|
133
|
-
thread_id: checkpoint.thread_id,
|
|
134
|
-
original_input: checkpoint.original_input,
|
|
135
|
-
messages: chat.messages.dup,
|
|
136
|
-
pending_tool_name: signal.tool_name,
|
|
137
|
-
pending_tool_args: signal.args,
|
|
138
|
-
pending_tool_call_id: signal.tool_call_id
|
|
139
|
-
)
|
|
140
|
-
return {output: nil, suspended: true, checkpoint: new_checkpoint, messages: chat.messages}
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
output = response.content
|
|
144
|
-
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
145
|
-
|
|
146
|
-
output = run_output_filters!(output)
|
|
147
|
-
|
|
148
|
-
{output: output, suspended: false, messages: chat.messages, usage: usage}
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
private
|
|
152
|
-
|
|
153
|
-
# Registers an on_tool_call hook on the chat object that raises SuspendSignal
|
|
154
|
-
# when an approval-required tool is about to be executed and no synchronous
|
|
155
|
-
# on_approval_required handler has been registered.
|
|
156
|
-
#
|
|
157
|
-
# Does nothing when:
|
|
158
|
-
# - a synchronous handler is already registered (@approval_handler is set), or
|
|
159
|
-
# - none of the agent's tools have requires_approval set.
|
|
160
|
-
#
|
|
161
|
-
# @param chat [RubyLLM::Chat]
|
|
162
|
-
# @api private
|
|
163
|
-
def _register_suspension_hook!(chat)
|
|
164
|
-
return if @approval_handler
|
|
165
|
-
return if self.class.tools.none? { |tc| tc.requires_approval }
|
|
166
|
-
|
|
167
|
-
chat.on_tool_call do |tool_call|
|
|
168
|
-
tool_instance = chat.tools[tool_call.name.to_sym]
|
|
169
|
-
if tool_instance&.requires_approval
|
|
170
|
-
raise SuspendSignal.new(
|
|
171
|
-
tool_name: tool_call.name,
|
|
172
|
-
args: tool_call.arguments,
|
|
173
|
-
tool_call_id: tool_call.id
|
|
174
|
-
)
|
|
175
|
-
end
|
|
176
|
-
end
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
# Returns the checkpoint idempotency store for this instance, lazily
|
|
180
|
-
# initialising a default in-memory {Phronomy::Agent::CheckpointStore}.
|
|
181
|
-
#
|
|
182
|
-
# @return [#consumed?, #consume!]
|
|
183
|
-
# @api private
|
|
184
|
-
def _checkpoint_store
|
|
185
|
-
@checkpoint_store ||= CheckpointStore.new
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
end
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Agent
|
|
5
|
-
# Raised internally inside the on_tool_call hook when an approval-required
|
|
6
|
-
# tool is encountered and no synchronous on_approval_required handler has
|
|
7
|
-
# been registered. Caught by Agent::Base#invoke_once to produce a
|
|
8
|
-
# suspended result hash containing a Checkpoint.
|
|
9
|
-
#
|
|
10
|
-
# This class is intentionally NOT part of the public API. Callers should
|
|
11
|
-
# @api private
|
|
12
|
-
# inspect the +:suspended+ key in the result hash returned by #invoke.
|
|
13
|
-
#
|
|
14
|
-
# @api private
|
|
15
|
-
class SuspendSignal < StandardError
|
|
16
|
-
# @return [String] the name of the tool that triggered the suspension
|
|
17
|
-
attr_reader :tool_name
|
|
18
|
-
|
|
19
|
-
# @return [Hash] the arguments the LLM passed to the tool
|
|
20
|
-
attr_reader :args
|
|
21
|
-
|
|
22
|
-
# @return [String] the tool_call_id from the LLM response
|
|
23
|
-
attr_reader :tool_call_id
|
|
24
|
-
|
|
25
|
-
# @param tool_name [String]
|
|
26
|
-
# @param args [Hash]
|
|
27
|
-
# @param tool_call_id [String]
|
|
28
|
-
# @api private
|
|
29
|
-
def initialize(tool_name:, args:, tool_call_id:)
|
|
30
|
-
super("Agent suspended waiting for approval of tool: #{tool_name}")
|
|
31
|
-
@tool_name = tool_name
|
|
32
|
-
@args = args
|
|
33
|
-
@tool_call_id = tool_call_id
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
data/lib/phronomy/context.rb
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Context management utilities: token estimation, budget calculation, and
|
|
5
|
-
# context assembly.
|
|
6
|
-
#
|
|
7
|
-
# Sub-modules are auto-loaded by Zeitwerk:
|
|
8
|
-
# Phronomy::LlmContextWindow::TokenEstimator
|
|
9
|
-
# Phronomy::LlmContextWindow::TokenBudget
|
|
10
|
-
module Context
|
|
11
|
-
end
|
|
12
|
-
end
|
data/lib/phronomy/tool.rb
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
class Workflow
|
|
5
|
-
# Event-driven execution wrapper for a single workflow run.
|
|
6
|
-
#
|
|
7
|
-
# Created by WorkflowRunner and registered with EventLoop. All public methods
|
|
8
|
-
# are called from the EventLoop thread — FSMSession is NOT thread-safe and must
|
|
9
|
-
# not be accessed concurrently from multiple threads.
|
|
10
|
-
#
|
|
11
|
-
# == Lifecycle
|
|
12
|
-
#
|
|
13
|
-
# register(session) → EventLoop posts :start → session.start
|
|
14
|
-
# ↓ (auto-transition present)
|
|
15
|
-
# EventLoop posts :state_completed → session.handle
|
|
16
|
-
# ↓ (repeat)
|
|
17
|
-
# session posts :finished or :halted
|
|
18
|
-
# ↓
|
|
19
|
-
# EventLoop pushes ctx to completion_queue → caller unblocks
|
|
20
|
-
#
|
|
21
|
-
# == Async IO pattern (EventLoop mode only)
|
|
22
|
-
#
|
|
23
|
-
# When a state has no auto-transition and is not a wait_state, but has an
|
|
24
|
-
# external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
|
|
25
|
-
# the FSMSession stays registered in the EventLoop and waits for that event.
|
|
26
|
-
# The entry action is expected to spawn an IO thread that posts the event back:
|
|
27
|
-
#
|
|
28
|
-
# entry :fetching, ->(ctx) {
|
|
29
|
-
# Thread.new {
|
|
30
|
-
# ctx.result = http.get(ctx.url)
|
|
31
|
-
# Phronomy::EventLoop.instance.post(
|
|
32
|
-
# Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
|
|
33
|
-
# )
|
|
34
|
-
# }
|
|
35
|
-
# }
|
|
36
|
-
# transition from: :fetching, on: :fetch_done, to: :process
|
|
37
|
-
class FSMSession
|
|
38
|
-
FINISH = WorkflowRunner::FINISH
|
|
39
|
-
|
|
40
|
-
# @return [String] workflow thread_id (matches WorkflowContext#thread_id)
|
|
41
|
-
attr_reader :id
|
|
42
|
-
|
|
43
|
-
# @param id [String]
|
|
44
|
-
# @param context [Object] includes Phronomy::WorkflowContext
|
|
45
|
-
# @param entry_point [Symbol] initial state name
|
|
46
|
-
# @param entry_actions [Hash] { state_name => [callable, ...] }
|
|
47
|
-
# @param auto_state_set [Hash] { state_name => true }
|
|
48
|
-
# @param declared_states [Array<Symbol>] all action state names
|
|
49
|
-
# @param wait_state_names [Array<Symbol>]
|
|
50
|
-
# @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
|
|
51
|
-
# @param phase_machine_class [Class] state_machines-backed phase tracker class
|
|
52
|
-
# @param recursion_limit [Integer]
|
|
53
|
-
# @param action_timeouts [Hash] { state_name => seconds }
|
|
54
|
-
# @param resume_event [Symbol, nil] external event to fire when resuming
|
|
55
|
-
# @param resume_phase [Symbol, nil] wait state name to resume from
|
|
56
|
-
# @api private
|
|
57
|
-
def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
|
|
58
|
-
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
59
|
-
recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
|
|
60
|
-
@id = id
|
|
61
|
-
@ctx = context
|
|
62
|
-
@entry_point = entry_point
|
|
63
|
-
@entry_actions = entry_actions
|
|
64
|
-
@auto_state_set = auto_state_set
|
|
65
|
-
@declared_states = declared_states
|
|
66
|
-
@wait_state_names = wait_state_names
|
|
67
|
-
@external_events = external_events
|
|
68
|
-
@phase_machine_class = phase_machine_class
|
|
69
|
-
@recursion_limit = recursion_limit
|
|
70
|
-
@action_timeouts = action_timeouts
|
|
71
|
-
@resume_event = resume_event
|
|
72
|
-
@resume_phase = resume_phase
|
|
73
|
-
@step = 0
|
|
74
|
-
@done = false
|
|
75
|
-
@current_state = nil
|
|
76
|
-
@tracker = nil
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Begins workflow execution. Called by EventLoop on :start event.
|
|
80
|
-
def start
|
|
81
|
-
if @resume_event
|
|
82
|
-
# Resume from wait state: position tracker at the wait state, then fire the
|
|
83
|
-
# external event. state_machines fires before_transition (exit) and
|
|
84
|
-
# after_transition (entry) callbacks, so both actions execute here.
|
|
85
|
-
@current_state = @resume_phase
|
|
86
|
-
@tracker = build_tracker(@current_state)
|
|
87
|
-
@tracker.context = @ctx
|
|
88
|
-
fire_and_advance!(@resume_event)
|
|
89
|
-
else
|
|
90
|
-
# Fresh start: state_machines does not fire callbacks on initialization,
|
|
91
|
-
# so we invoke the entry action for the initial state manually.
|
|
92
|
-
@current_state = @entry_point
|
|
93
|
-
@tracker = build_tracker(@current_state)
|
|
94
|
-
@tracker.context = @ctx
|
|
95
|
-
(@entry_actions[@current_state] || []).each do |c|
|
|
96
|
-
result = c.call(@ctx)
|
|
97
|
-
if result.is_a?(Phronomy::Task)
|
|
98
|
-
# Awaitable action: spawn a task to await without blocking EventLoop.
|
|
99
|
-
@tracker.async_pending = true
|
|
100
|
-
session_id = @id
|
|
101
|
-
current_state_name = @current_state
|
|
102
|
-
timeout_secs = @action_timeouts[current_state_name]
|
|
103
|
-
Phronomy::Runtime.instance.spawn(name: "fsm-await-#{session_id}") do
|
|
104
|
-
if timeout_secs
|
|
105
|
-
if result.join(timeout_secs).nil?
|
|
106
|
-
result.cancel!
|
|
107
|
-
raise Phronomy::ActionTimeoutError,
|
|
108
|
-
"Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
task_result = result.await
|
|
112
|
-
if task_result.is_a?(Phronomy::WorkflowContext)
|
|
113
|
-
event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
|
|
114
|
-
else
|
|
115
|
-
event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
|
|
116
|
-
end
|
|
117
|
-
rescue => e
|
|
118
|
-
event_loop.post(Event.new(type: :error, target_id: session_id, payload: e))
|
|
119
|
-
end
|
|
120
|
-
break # Only one async action at a time per state
|
|
121
|
-
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
122
|
-
@ctx = result
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
@tracker.context = @ctx
|
|
126
|
-
advance_or_halt unless @tracker.async_pending
|
|
127
|
-
end
|
|
128
|
-
rescue => e
|
|
129
|
-
finish_with_error(e)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# Processes an event dispatched from EventLoop.
|
|
133
|
-
# Called for :state_completed, :action_completed, and all user-defined external events.
|
|
134
|
-
#
|
|
135
|
-
# @param event [Phronomy::Event]
|
|
136
|
-
# @api private
|
|
137
|
-
def handle(event)
|
|
138
|
-
return if @done
|
|
139
|
-
|
|
140
|
-
if event.type == :action_completed
|
|
141
|
-
# An awaitable entry action completed: update context and advance.
|
|
142
|
-
@ctx = event.payload if event.payload.is_a?(Phronomy::WorkflowContext)
|
|
143
|
-
@tracker.context = @ctx
|
|
144
|
-
@tracker.async_pending = false # Reset flag set by start or fire_and_advance!
|
|
145
|
-
advance_or_halt
|
|
146
|
-
return
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
fire_and_advance!(event.type)
|
|
150
|
-
rescue => e
|
|
151
|
-
finish_with_error(e)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
private
|
|
155
|
-
|
|
156
|
-
# Fires event_name on the phase tracker, updates @current_state, then
|
|
157
|
-
# calls advance_or_halt to decide what to do next.
|
|
158
|
-
def fire_and_advance!(event_name)
|
|
159
|
-
if @step >= @recursion_limit
|
|
160
|
-
raise Phronomy::RecursionLimitError,
|
|
161
|
-
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
fire_event!(@tracker, event_name, @current_state)
|
|
165
|
-
@ctx = @tracker.context
|
|
166
|
-
next_phase = @tracker.phase.to_sym
|
|
167
|
-
# When next_phase == @current_state, no transition matched → treat as terminal.
|
|
168
|
-
@current_state = (next_phase == @current_state) ? FINISH : next_phase
|
|
169
|
-
@step += 1
|
|
170
|
-
|
|
171
|
-
# If an entry action returned a Task, the after_transition callback set
|
|
172
|
-
# async_pending = true and spawned a thread. Skip advance_or_halt — the
|
|
173
|
-
# background thread will post :action_completed or :state_completed.
|
|
174
|
-
if @tracker.async_pending
|
|
175
|
-
@tracker.async_pending = false
|
|
176
|
-
return
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
advance_or_halt
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# Determines the next action after the FSM has entered @current_state.
|
|
183
|
-
def advance_or_halt
|
|
184
|
-
return finish! if @current_state == FINISH
|
|
185
|
-
|
|
186
|
-
if @wait_state_names.include?(@current_state)
|
|
187
|
-
return halt!
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
if @auto_state_set.key?(@current_state)
|
|
191
|
-
event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
192
|
-
return
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
if has_external_event_from?(@current_state)
|
|
196
|
-
# Async IO pattern: the entry action spawned an IO thread that will post
|
|
197
|
-
# an external event back. Stay registered; do nothing here.
|
|
198
|
-
return
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
# No transition declared — validate the state is known, then treat as terminal.
|
|
202
|
-
unless @declared_states.include?(@current_state)
|
|
203
|
-
raise ArgumentError, "State #{@current_state.inspect} is not defined"
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
finish!
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def finish!
|
|
210
|
-
@done = true
|
|
211
|
-
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
212
|
-
event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def halt!
|
|
216
|
-
@done = true
|
|
217
|
-
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
218
|
-
event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def finish_with_error(err)
|
|
222
|
-
@done = true
|
|
223
|
-
event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def fire_event!(tracker, event_name, from_state)
|
|
227
|
-
return if tracker.send(event_name)
|
|
228
|
-
|
|
229
|
-
raise ArgumentError,
|
|
230
|
-
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
231
|
-
"Ensure at least one guard matches or add a fallback (no-guard) transition."
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def has_external_event_from?(state)
|
|
235
|
-
@external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def build_tracker(from_state)
|
|
239
|
-
machine = @phase_machine_class.new
|
|
240
|
-
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
241
|
-
machine
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def event_loop
|
|
245
|
-
Phronomy::EventLoop.instance
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
end
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|