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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -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 background IO thread that runs the agent's full
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 IO thread, the +:phronomy_agent_parallel_tools+ thread-local
23
- # flag is set to +true+ so that {Agent::Base#build_chat} returns a
24
- # {ParallelToolChat} instance, enabling concurrent tool dispatch when the LLM
25
- # returns multiple tool calls in one response.
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 [String, nil] EventLoop id of the parent
76
- # FSMSession; when set, a
77
- # +:child_completed+ event is posted
78
- # on completion
79
- # @param result_writer [Proc, nil] optional callable invoked with the
80
- # result hash <b>before</b>
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, result_writer: 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 IO thread.
95
+ # Transitions to +:running+ and spawns the agent task.
106
96
  def start
107
97
  @current_phase = :running
108
- spawn_agent_thread
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 the background IO thread that runs the agent invocation.
121
- # Captures all instance variables by value so the thread closure is
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 spawn_agent_thread
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
- Thread.new do
134
- # Enable parallel tool dispatch inside this IO thread.
135
- Thread.current[:phronomy_agent_parallel_tools] = true
136
- # Forward the concurrency cap to ParallelToolChat.
137
- Thread.current[:phronomy_max_parallel_tools] =
138
- agent.class.respond_to?(:max_parallel_tools) ? agent.class.max_parallel_tools : 10
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: :finished, target_id: fsm_id, payload: result)
134
+ Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
160
135
  )
161
- rescue => e
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: :error, target_id: fsm_id, payload: e)
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