phronomy 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
data/lib/phronomy/agent/fsm.rb
CHANGED
|
@@ -8,21 +8,21 @@ module Phronomy
|
|
|
8
8
|
#
|
|
9
9
|
# +AgentFSM+ implements the minimal interface expected by {Phronomy::EventLoop}
|
|
10
10
|
# (+#id+, +#start+, +#handle+) so it can be managed alongside
|
|
11
|
-
# {Phronomy::FSMSession} instances. It is *not* a traditional finite-state
|
|
11
|
+
# {Phronomy::Agent::Lifecycle::FSMSession} instances. It is *not* a traditional finite-state
|
|
12
12
|
# machine; the name reflects its role in the EventLoop rather than internal
|
|
13
13
|
# state transitions.
|
|
14
14
|
#
|
|
15
15
|
# == Execution model
|
|
16
16
|
#
|
|
17
17
|
# {#start} is called by the EventLoop on the +:start+ event. It immediately
|
|
18
|
-
# returns after spawning a
|
|
18
|
+
# returns after spawning a {Phronomy::Task} that runs the agent's full
|
|
19
19
|
# invocation pipeline (via +_invoke_impl+). The EventLoop thread is never
|
|
20
20
|
# blocked by agent execution.
|
|
21
21
|
#
|
|
22
|
-
# Inside the
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
26
|
#
|
|
27
27
|
# == Completion events
|
|
28
28
|
#
|
|
@@ -72,40 +72,30 @@ module Phronomy
|
|
|
72
72
|
# auto-generated when nil
|
|
73
73
|
# @param config [Hash] invocation config forwarded to
|
|
74
74
|
# +_invoke_impl+
|
|
75
|
-
# @param parent_id
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
# +:child_completed+ is posted.
|
|
82
|
-
# Use this to write the agent output
|
|
83
|
-
# back into the parent WorkflowContext.
|
|
84
|
-
# Thread::Queue provides the
|
|
85
|
-
# happens-before guarantee.
|
|
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.
|
|
86
81
|
#
|
|
87
|
-
# @example Writing result into context
|
|
88
|
-
# entry :run_agent, ->(ctx) {
|
|
89
|
-
# MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
|
|
90
|
-
# }
|
|
91
82
|
# @api private
|
|
92
|
-
def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil
|
|
83
|
+
def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil)
|
|
93
84
|
@agent = agent
|
|
94
85
|
@input = input
|
|
95
86
|
@messages = Array(messages).dup
|
|
96
87
|
@thread_id = thread_id || SecureRandom.uuid
|
|
97
88
|
@config = config
|
|
98
89
|
@parent_id = parent_id
|
|
99
|
-
@result_writer = result_writer
|
|
100
90
|
@id = @thread_id
|
|
101
91
|
@current_phase = :idle
|
|
102
92
|
end
|
|
103
93
|
|
|
104
94
|
# Called by {EventLoop} on the +:start+ event.
|
|
105
|
-
# Transitions to +:running+ and spawns the agent
|
|
95
|
+
# Transitions to +:running+ and spawns the agent task.
|
|
106
96
|
def start
|
|
107
97
|
@current_phase = :running
|
|
108
|
-
|
|
98
|
+
spawn_agent_task
|
|
109
99
|
end
|
|
110
100
|
|
|
111
101
|
# Called by {EventLoop} for external events dispatched to this id.
|
|
@@ -117,10 +107,10 @@ module Phronomy
|
|
|
117
107
|
|
|
118
108
|
private
|
|
119
109
|
|
|
120
|
-
# Spawns
|
|
121
|
-
# Captures all instance variables by value so the
|
|
110
|
+
# Spawns a {Phronomy::Task} that runs the agent invocation pipeline.
|
|
111
|
+
# Captures all instance variables by value so the task closure is
|
|
122
112
|
# safe even if the FSM object is modified (though it is not in practice).
|
|
123
|
-
def
|
|
113
|
+
def spawn_agent_task
|
|
124
114
|
agent = @agent
|
|
125
115
|
input = @input
|
|
126
116
|
messages = @messages
|
|
@@ -128,51 +118,38 @@ module Phronomy
|
|
|
128
118
|
config = @config
|
|
129
119
|
fsm_id = @id
|
|
130
120
|
parent_id = @parent_id
|
|
131
|
-
result_writer = @result_writer
|
|
132
121
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
begin
|
|
141
|
-
result = agent.send(:_invoke_impl,
|
|
142
|
-
input,
|
|
143
|
-
messages: messages,
|
|
144
|
-
thread_id: thread_id,
|
|
145
|
-
config: config)
|
|
146
|
-
|
|
147
|
-
if parent_id
|
|
148
|
-
# Let the caller write the result into the context BEFORE the
|
|
149
|
-
# parent FSMSession advances. Thread::Queue provides the
|
|
150
|
-
# happens-before guarantee — no Mutex needed.
|
|
151
|
-
result_writer&.call(result)
|
|
152
|
-
|
|
153
|
-
Phronomy::EventLoop.instance.post(
|
|
154
|
-
Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
|
|
155
|
-
)
|
|
156
|
-
end
|
|
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)
|
|
157
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.
|
|
158
133
|
Phronomy::EventLoop.instance.post(
|
|
159
|
-
Phronomy::Event.new(type: :
|
|
134
|
+
Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
|
|
160
135
|
)
|
|
161
|
-
|
|
162
|
-
if parent_id
|
|
163
|
-
Phronomy::EventLoop.instance.post(
|
|
164
|
-
Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
|
|
165
|
-
)
|
|
166
|
-
end
|
|
136
|
+
end
|
|
167
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
|
|
168
143
|
Phronomy::EventLoop.instance.post(
|
|
169
|
-
Phronomy::Event.new(type: :
|
|
144
|
+
Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
|
|
170
145
|
)
|
|
171
|
-
ensure
|
|
172
|
-
# Clear the thread-local context cache for this agent so the IO
|
|
173
|
-
# thread's cache does not grow unboundedly across invocations.
|
|
174
|
-
Thread.current[:phronomy_context_version_caches]&.delete(agent.object_id)
|
|
175
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.
|
|
176
153
|
end
|
|
177
154
|
end
|
|
178
155
|
end
|
|
@@ -0,0 +1,99 @@
|
|
|
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(inp, messages: msgs, thread_id: tid, config: cfg)
|
|
47
|
+
apply_instructions(chat, context[:system]) if context[:system]
|
|
48
|
+
context[:messages].each { |msg| chat.messages << msg }
|
|
49
|
+
|
|
50
|
+
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
51
|
+
run_before_completion_hooks!(chat, cfg)
|
|
52
|
+
|
|
53
|
+
# Register suspension hook for approval-required tools (no-op when a
|
|
54
|
+
# synchronous on_approval_required handler is already registered).
|
|
55
|
+
_register_suspension_hook!(chat)
|
|
56
|
+
|
|
57
|
+
# Check for cancellation immediately before the LLM call.
|
|
58
|
+
check_cancellation!(cfg, "invocation cancelled before LLM call")
|
|
59
|
+
|
|
60
|
+
# Forward the cancellation token to ParallelToolChat explicitly
|
|
61
|
+
# via the chat instance so that tool dispatch batches can observe
|
|
62
|
+
# cancellation without needing Thread.current.
|
|
63
|
+
chat.cancellation_token = cfg[:cancellation_token] if chat.respond_to?(:cancellation_token=)
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
# Route the LLM call through the configured LLMAdapter so that the
|
|
67
|
+
# blocking HTTP request runs inside BlockingAdapterPool and the
|
|
68
|
+
# adapter can be swapped without changing agent code.
|
|
69
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
70
|
+
response = adapter.complete_async(chat, user_message, config: cfg).await
|
|
71
|
+
rescue SuspendSignal => signal
|
|
72
|
+
checkpoint = Checkpoint.new(
|
|
73
|
+
thread_id: tid,
|
|
74
|
+
original_input: inp,
|
|
75
|
+
messages: chat.messages.dup,
|
|
76
|
+
pending_tool_name: signal.tool_name,
|
|
77
|
+
pending_tool_args: signal.args,
|
|
78
|
+
pending_tool_call_id: signal.tool_call_id
|
|
79
|
+
)
|
|
80
|
+
suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
|
|
81
|
+
next [suspended_result, nil]
|
|
82
|
+
ensure
|
|
83
|
+
# Clear the chat's cancellation token reference after each LLM call.
|
|
84
|
+
chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
output = response.content
|
|
88
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
89
|
+
|
|
90
|
+
# Run output guardrails before returning to the caller.
|
|
91
|
+
run_output_guardrails!(output)
|
|
92
|
+
|
|
93
|
+
result = {output: output, messages: chat.messages, usage: usage}
|
|
94
|
+
[result, usage]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Lifecycle
|
|
6
|
+
# Event-driven execution wrapper for a single workflow run.
|
|
7
|
+
#
|
|
8
|
+
# Created by WorkflowRunner and registered with EventLoop. All public methods
|
|
9
|
+
# are called from the EventLoop thread — FSMSession is NOT thread-safe and must
|
|
10
|
+
# not be accessed concurrently from multiple threads.
|
|
11
|
+
#
|
|
12
|
+
# == Lifecycle
|
|
13
|
+
#
|
|
14
|
+
# register(session) → EventLoop posts :start → session.start
|
|
15
|
+
# ↓ (auto-transition present)
|
|
16
|
+
# EventLoop posts :state_completed → session.handle
|
|
17
|
+
# ↓ (repeat)
|
|
18
|
+
# session posts :finished or :halted
|
|
19
|
+
# ↓
|
|
20
|
+
# EventLoop pushes ctx to completion_queue → caller unblocks
|
|
21
|
+
#
|
|
22
|
+
# == Async IO pattern (EventLoop mode only)
|
|
23
|
+
#
|
|
24
|
+
# When a state has no auto-transition and is not a wait_state, but has an
|
|
25
|
+
# external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
|
|
26
|
+
# the FSMSession stays registered in the EventLoop and waits for that event.
|
|
27
|
+
# The entry action is expected to spawn an IO thread that posts the event back:
|
|
28
|
+
#
|
|
29
|
+
# entry :fetching, ->(ctx) {
|
|
30
|
+
# Thread.new {
|
|
31
|
+
# ctx.result = http.get(ctx.url)
|
|
32
|
+
# Phronomy::EventLoop.instance.post(
|
|
33
|
+
# Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
|
|
34
|
+
# )
|
|
35
|
+
# }
|
|
36
|
+
# }
|
|
37
|
+
# transition from: :fetching, on: :fetch_done, to: :process
|
|
38
|
+
class FSMSession
|
|
39
|
+
FINISH = WorkflowRunner::FINISH
|
|
40
|
+
|
|
41
|
+
# @return [String] workflow thread_id (matches WorkflowContext#thread_id)
|
|
42
|
+
attr_reader :id
|
|
43
|
+
|
|
44
|
+
# @param id [String]
|
|
45
|
+
# @param context [Object] includes Phronomy::WorkflowContext
|
|
46
|
+
# @param entry_point [Symbol] initial state name
|
|
47
|
+
# @param entry_actions [Hash] { state_name => [callable, ...] }
|
|
48
|
+
# @param auto_state_set [Hash] { state_name => true }
|
|
49
|
+
# @param declared_states [Array<Symbol>] all action state names
|
|
50
|
+
# @param wait_state_names [Array<Symbol>]
|
|
51
|
+
# @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
|
|
52
|
+
# @param phase_machine_class [Class] state_machines-backed phase tracker class
|
|
53
|
+
# @param recursion_limit [Integer]
|
|
54
|
+
# @param action_timeouts [Hash] { state_name => seconds }
|
|
55
|
+
# @param resume_event [Symbol, nil] external event to fire when resuming
|
|
56
|
+
# @param resume_phase [Symbol, nil] wait state name to resume from
|
|
57
|
+
# @api private
|
|
58
|
+
def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
|
|
59
|
+
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
60
|
+
recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
|
|
61
|
+
@id = id
|
|
62
|
+
@ctx = context
|
|
63
|
+
@entry_point = entry_point
|
|
64
|
+
@entry_actions = entry_actions
|
|
65
|
+
@auto_state_set = auto_state_set
|
|
66
|
+
@declared_states = declared_states
|
|
67
|
+
@wait_state_names = wait_state_names
|
|
68
|
+
@external_events = external_events
|
|
69
|
+
@phase_machine_class = phase_machine_class
|
|
70
|
+
@recursion_limit = recursion_limit
|
|
71
|
+
@action_timeouts = action_timeouts
|
|
72
|
+
@resume_event = resume_event
|
|
73
|
+
@resume_phase = resume_phase
|
|
74
|
+
@step = 0
|
|
75
|
+
@done = false
|
|
76
|
+
@current_state = nil
|
|
77
|
+
@tracker = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Begins workflow execution. Called by EventLoop on :start event.
|
|
81
|
+
def start
|
|
82
|
+
if @resume_event
|
|
83
|
+
# Resume from wait state: position tracker at the wait state, then fire the
|
|
84
|
+
# external event. state_machines fires before_transition (exit) and
|
|
85
|
+
# after_transition (entry) callbacks, so both actions execute here.
|
|
86
|
+
@current_state = @resume_phase
|
|
87
|
+
@tracker = build_tracker(@current_state)
|
|
88
|
+
@tracker.context = @ctx
|
|
89
|
+
fire_and_advance!(@resume_event)
|
|
90
|
+
else
|
|
91
|
+
# Fresh start: state_machines does not fire callbacks on initialization,
|
|
92
|
+
# so we invoke the entry action for the initial state manually.
|
|
93
|
+
@current_state = @entry_point
|
|
94
|
+
@tracker = build_tracker(@current_state)
|
|
95
|
+
@tracker.context = @ctx
|
|
96
|
+
(@entry_actions[@current_state] || []).each do |c|
|
|
97
|
+
result = c.call(@ctx)
|
|
98
|
+
if result.is_a?(Phronomy::Task)
|
|
99
|
+
# Awaitable action: spawn a task to await without blocking EventLoop.
|
|
100
|
+
@tracker.async_pending = true
|
|
101
|
+
session_id = @id
|
|
102
|
+
current_state_name = @current_state
|
|
103
|
+
timeout_secs = @action_timeouts[current_state_name]
|
|
104
|
+
Phronomy::Runtime.instance.spawn(name: "fsm-await-#{session_id}") do
|
|
105
|
+
if timeout_secs
|
|
106
|
+
if result.join(timeout_secs).nil?
|
|
107
|
+
result.cancel!
|
|
108
|
+
raise Phronomy::ActionTimeoutError,
|
|
109
|
+
"Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
task_result = result.await
|
|
113
|
+
if task_result.is_a?(Phronomy::WorkflowContext)
|
|
114
|
+
event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
|
|
115
|
+
else
|
|
116
|
+
event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
|
|
117
|
+
end
|
|
118
|
+
rescue => e
|
|
119
|
+
event_loop.post(Event.new(type: :error, target_id: session_id, payload: e))
|
|
120
|
+
end
|
|
121
|
+
break # Only one async action at a time per state
|
|
122
|
+
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
123
|
+
@ctx = result
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
@tracker.context = @ctx
|
|
127
|
+
advance_or_halt unless @tracker.async_pending
|
|
128
|
+
end
|
|
129
|
+
rescue => e
|
|
130
|
+
finish_with_error(e)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Processes an event dispatched from EventLoop.
|
|
134
|
+
# Called for :state_completed, :action_completed, and all user-defined external events.
|
|
135
|
+
#
|
|
136
|
+
# @param event [Phronomy::Event]
|
|
137
|
+
# @api private
|
|
138
|
+
def handle(event)
|
|
139
|
+
return if @done
|
|
140
|
+
|
|
141
|
+
if event.type == :action_completed
|
|
142
|
+
# An awaitable entry action completed: update context and advance.
|
|
143
|
+
@ctx = event.payload if event.payload.is_a?(Phronomy::WorkflowContext)
|
|
144
|
+
@tracker.context = @ctx
|
|
145
|
+
@tracker.async_pending = false # Reset flag set by start or fire_and_advance!
|
|
146
|
+
advance_or_halt
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
fire_and_advance!(event.type)
|
|
151
|
+
rescue => e
|
|
152
|
+
finish_with_error(e)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# Fires event_name on the phase tracker, updates @current_state, then
|
|
158
|
+
# calls advance_or_halt to decide what to do next.
|
|
159
|
+
def fire_and_advance!(event_name)
|
|
160
|
+
if @step >= @recursion_limit
|
|
161
|
+
raise Phronomy::RecursionLimitError,
|
|
162
|
+
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
fire_event!(@tracker, event_name, @current_state)
|
|
166
|
+
@ctx = @tracker.context
|
|
167
|
+
next_phase = @tracker.phase.to_sym
|
|
168
|
+
# When next_phase == @current_state, no transition matched → treat as terminal.
|
|
169
|
+
@current_state = (next_phase == @current_state) ? FINISH : next_phase
|
|
170
|
+
@step += 1
|
|
171
|
+
|
|
172
|
+
# If an entry action returned a Task, the after_transition callback set
|
|
173
|
+
# async_pending = true and spawned a thread. Skip advance_or_halt — the
|
|
174
|
+
# background thread will post :action_completed or :state_completed.
|
|
175
|
+
if @tracker.async_pending
|
|
176
|
+
@tracker.async_pending = false
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
advance_or_halt
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Determines the next action after the FSM has entered @current_state.
|
|
184
|
+
def advance_or_halt
|
|
185
|
+
return finish! if @current_state == FINISH
|
|
186
|
+
|
|
187
|
+
if @wait_state_names.include?(@current_state)
|
|
188
|
+
return halt!
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if @auto_state_set.key?(@current_state)
|
|
192
|
+
event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
193
|
+
return
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if has_external_event_from?(@current_state)
|
|
197
|
+
# Async IO pattern: the entry action spawned an IO thread that will post
|
|
198
|
+
# an external event back. Stay registered; do nothing here.
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# No transition declared — validate the state is known, then treat as terminal.
|
|
203
|
+
unless @declared_states.include?(@current_state)
|
|
204
|
+
raise ArgumentError, "State #{@current_state.inspect} is not defined"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
finish!
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def finish!
|
|
211
|
+
@done = true
|
|
212
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
213
|
+
event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def halt!
|
|
217
|
+
@done = true
|
|
218
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
219
|
+
event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def finish_with_error(err)
|
|
223
|
+
@done = true
|
|
224
|
+
event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def fire_event!(tracker, event_name, from_state)
|
|
228
|
+
return if tracker.send(event_name)
|
|
229
|
+
|
|
230
|
+
raise ArgumentError,
|
|
231
|
+
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
232
|
+
"Ensure at least one guard matches or add a fallback (no-guard) transition."
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def has_external_event_from?(state)
|
|
236
|
+
@external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def build_tracker(from_state)
|
|
240
|
+
machine = @phase_machine_class.new
|
|
241
|
+
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
242
|
+
machine
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def event_loop
|
|
246
|
+
Phronomy::EventLoop.instance
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|