phronomy 0.7.1 → 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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -16
  3. data/benchmark/bench_context_assembler.rb +2 -2
  4. data/benchmark/bench_regression.rb +5 -5
  5. data/benchmark/bench_token_estimator.rb +5 -5
  6. data/benchmark/bench_tool_schema.rb +1 -1
  7. data/benchmark/bench_vector_store.rb +1 -1
  8. data/lib/phronomy/agent/base.rb +86 -123
  9. data/lib/phronomy/agent/checkpoint.rb +118 -0
  10. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  11. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  12. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  13. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  14. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  15. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  16. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  17. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  18. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  19. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  20. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  21. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  23. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  24. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  25. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  26. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  27. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  28. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  29. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  30. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  31. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  32. data/lib/phronomy/agent/fsm.rb +1 -1
  33. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  34. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  35. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  36. data/lib/phronomy/agent/react_agent.rb +19 -14
  37. data/lib/phronomy/agent/runner.rb +2 -2
  38. data/lib/phronomy/agent/tool_executor.rb +108 -0
  39. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  40. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  41. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  42. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  43. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  44. data/lib/phronomy/concurrency/deadline.rb +65 -0
  45. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
  46. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  47. data/lib/phronomy/context.rb +2 -8
  48. data/lib/phronomy/embeddings.rb +2 -2
  49. data/lib/phronomy/eval/runner.rb +4 -0
  50. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  51. data/lib/phronomy/event_loop.rb +7 -7
  52. data/lib/phronomy/invocation_context.rb +3 -3
  53. data/lib/phronomy/knowledge_source.rb +0 -5
  54. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  55. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  56. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  57. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  58. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  59. data/lib/phronomy/loader.rb +4 -4
  60. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  61. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
  62. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  63. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  64. data/lib/phronomy/runtime.rb +19 -4
  65. data/lib/phronomy/splitter.rb +3 -3
  66. data/lib/phronomy/task_group.rb +1 -1
  67. data/lib/phronomy/tool/base.rb +50 -9
  68. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  69. data/lib/phronomy/vector_store.rb +2 -2
  70. data/lib/phronomy/version.rb +1 -1
  71. data/lib/phronomy/workflow_context.rb +8 -0
  72. data/lib/phronomy/workflow_runner.rb +11 -131
  73. data/lib/phronomy.rb +1 -0
  74. metadata +44 -42
  75. data/lib/phronomy/async_queue.rb +0 -155
  76. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  77. data/lib/phronomy/cancellation_scope.rb +0 -123
  78. data/lib/phronomy/cancellation_token.rb +0 -133
  79. data/lib/phronomy/concurrency_gate.rb +0 -155
  80. data/lib/phronomy/context/compaction_context.rb +0 -111
  81. data/lib/phronomy/context/trigger_context.rb +0 -39
  82. data/lib/phronomy/context/trim_context.rb +0 -75
  83. data/lib/phronomy/deadline.rb +0 -63
  84. data/lib/phronomy/embeddings/base.rb +0 -39
  85. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  86. data/lib/phronomy/fsm_session.rb +0 -247
  87. data/lib/phronomy/knowledge_source/base.rb +0 -54
  88. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  89. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  90. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  91. data/lib/phronomy/loader/base.rb +0 -25
  92. data/lib/phronomy/loader/csv_loader.rb +0 -56
  93. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  94. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  95. data/lib/phronomy/prompt_template.rb +0 -96
  96. data/lib/phronomy/splitter/base.rb +0 -47
  97. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  98. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  99. data/lib/phronomy/tool_executor.rb +0 -106
  100. data/lib/phronomy/vector_store/async_backend.rb +0 -110
  101. data/lib/phronomy/vector_store/base.rb +0 -89
  102. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  103. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  104. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -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
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "state_machines"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ module Lifecycle
8
+ # Builds the anonymous state-machine Class used by {WorkflowRunner} to track
9
+ # workflow phase transitions.
10
+ #
11
+ # Extracted from {WorkflowRunner#build_phase_machine_class} to reduce the
12
+ # span of WorkflowRunner's initializer and to give the FSM construction
13
+ # logic an explicit, testable home.
14
+ #
15
+ # Call {#build} to obtain the generated +Class+. The returned class responds
16
+ # to +#context+ / +#context=+ and +#async_pending+ / +#async_pending=+, and
17
+ # has a +state_machine :phase+ definition with all registered transitions and
18
+ # callbacks.
19
+ #
20
+ # @api private
21
+ class PhaseMachineBuilder
22
+ # @param entry_point [Symbol] initial state for the phase machine
23
+ # @param declared_states [Array<Symbol>] all states declared in the workflow
24
+ # @param wait_state_names [Array<Symbol>] states that wait for external events
25
+ # @param external_events [Hash{Symbol => Array<Hash>}]
26
+ # +{ event_name => [{from:, to:, guard:}, ...] }+
27
+ # @param entry_actions [Hash{Symbol => Array<#call>}]
28
+ # +{ state_name => [callable, ...] }+
29
+ # @param action_timeouts [Hash{Symbol => Numeric}]
30
+ # +{ state_name => seconds }+
31
+ # @param auto_transitions [Array<Hash>]
32
+ # +[{ from:, to:, guard: }, ...]+ — all auto-fire transitions
33
+ # @param exit_actions [Hash{Symbol => Array<#call>}]
34
+ # +{ state_name => [callable, ...] }+
35
+ # @api private
36
+ def initialize(
37
+ entry_point:,
38
+ declared_states:,
39
+ wait_state_names:,
40
+ external_events:,
41
+ entry_actions:,
42
+ action_timeouts:,
43
+ auto_transitions:,
44
+ exit_actions:
45
+ )
46
+ @entry_point = entry_point
47
+ @declared_states = declared_states
48
+ @wait_state_names = wait_state_names
49
+ @external_events = external_events
50
+ @entry_actions = entry_actions
51
+ @action_timeouts = action_timeouts
52
+ @auto_transitions = auto_transitions
53
+ @exit_actions = exit_actions
54
+ end
55
+
56
+ # Constructs and returns the anonymous phase-machine Class.
57
+ #
58
+ # @return [Class] an anonymous class with a +state_machine :phase+ definition
59
+ # @raise [ArgumentError] if state_machines raises during class construction
60
+ # @api private
61
+ def build
62
+ entry = @entry_point
63
+ all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
64
+ auto_trans = @auto_transitions
65
+ ext_events = @external_events
66
+ entry_acts = @entry_actions
67
+ exit_acts = @exit_actions
68
+ act_timeouts = @action_timeouts
69
+ build_cb = method(:build_entry_callback)
70
+
71
+ Class.new do
72
+ # Holds the current WorkflowContext so guards and callbacks can read it.
73
+ attr_accessor :context
74
+
75
+ # Set to true by an entry action that returned an awaitable Task.
76
+ # When true, FSMSession skips the automatic advance_or_halt step and
77
+ # waits for the async worker thread to post a state_completed event back.
78
+ attr_accessor :async_pending
79
+
80
+ state_machine :phase, initial: entry do
81
+ all_states.each { |s| state s }
82
+
83
+ # Auto-fire transitions: all auto transitions unified under :state_completed.
84
+ # Includes unguarded (unconditional) and guarded (conditional) transitions.
85
+ # Declaration order is preserved; guards are evaluated before unguarded fallbacks.
86
+ event :state_completed do
87
+ auto_trans.each do |t|
88
+ if t[:guard]
89
+ guard_proc = t[:guard]
90
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
91
+ else
92
+ transition t[:from] => t[:to]
93
+ end
94
+ end
95
+ end
96
+
97
+ # External events: human-in-the-loop triggers from wait states.
98
+ ext_events.each do |ev_name, transitions|
99
+ event ev_name do
100
+ transitions.each do |t|
101
+ if t[:guard]
102
+ guard_proc = t[:guard]
103
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
104
+ else
105
+ transition t[:from] => t[:to]
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ # Entry callbacks: fire after_transition into each state.
112
+ # Each callable is registered as a separate callback; state_machines
113
+ # accumulates them and fires in declaration order.
114
+ # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
115
+ # the returned context replaces the current one on the tracker.
116
+ entry_acts.each do |state_name, callables|
117
+ callables.each do |callable|
118
+ cb = build_cb.call(callable, state_name, act_timeouts[state_name])
119
+ after_transition to: state_name, &cb
120
+ end
121
+ end
122
+
123
+ # Exit callbacks: fire before_transition out of each state.
124
+ # Each callable is registered as a separate callback; state_machines
125
+ # accumulates them and fires in declaration order.
126
+ exit_acts.each do |state_name, callables|
127
+ callables.each do |callable|
128
+ before_transition from: state_name do |machine|
129
+ callable.call(machine.context)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ rescue => e
136
+ raise ArgumentError, "Failed to build phase machine: #{e.message}"
137
+ end
138
+
139
+ private
140
+
141
+ # Returns a proc suitable for use as an +after_transition+ callback.
142
+ #
143
+ # The returned proc accepts a single argument (the phase machine instance),
144
+ # invokes the entry action callable with the current context, then delegates
145
+ # the result to {#handle_entry_action_result}. Capturing this in the
146
+ # builder's scope lets the anonymous +Class.new+ block stay slim.
147
+ #
148
+ # @param callable [#call] the entry action
149
+ # @param state_name [Symbol] name of the target state (for error messages)
150
+ # @param timeout_secs [Numeric, nil] seconds before ActionTimeoutError
151
+ # @return [Proc]
152
+ # @api private
153
+ def build_entry_callback(callable, state_name, timeout_secs)
154
+ handle = method(:handle_entry_action_result)
155
+ ->(machine) {
156
+ result = callable.call(machine.context)
157
+ handle.call(machine, result, state_name, timeout_secs)
158
+ }
159
+ end
160
+
161
+ # Dispatches the return value of an entry action callable.
162
+ #
163
+ # - +Phronomy::Task+ → async or blocking task handling
164
+ # - +Phronomy::WorkflowContext+ → replaces the machine's context directly
165
+ # - anything else → ignored
166
+ #
167
+ # @param machine [Object] phase machine instance
168
+ # @param result [Object] return value of the entry callable
169
+ # @param state_name [Symbol] name of the entered state
170
+ # @param timeout_secs [Numeric, nil] optional timeout in seconds
171
+ # @api private
172
+ def handle_entry_action_result(machine, result, state_name, timeout_secs)
173
+ if result.is_a?(Phronomy::Task)
174
+ if Phronomy.configuration.event_loop
175
+ dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
176
+ else
177
+ await_task_blocking(machine, result, state_name, timeout_secs)
178
+ end
179
+ elsif result.is_a?(Phronomy::WorkflowContext)
180
+ machine.context = result
181
+ end
182
+ end
183
+
184
+ # Handles a +Phronomy::Task+ return value in EventLoop mode.
185
+ #
186
+ # Marks the machine as async-pending and spawns a cooperative background
187
+ # task that awaits the result, then posts the appropriate event back to
188
+ # the EventLoop. +FSMSession+ will skip the automatic +advance_or_halt+
189
+ # step while +async_pending+ is true.
190
+ #
191
+ # @param machine [Object] phase machine instance
192
+ # @param result [Phronomy::Task]
193
+ # @param state_name [Symbol]
194
+ # @param timeout_secs [Numeric, nil]
195
+ # @api private
196
+ def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
197
+ machine.async_pending = true
198
+ thread_id = machine.context.thread_id
199
+ Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
200
+ enforce_timeout!(result, state_name, timeout_secs)
201
+ task_result = result.await
202
+ ev = if task_result.is_a?(Phronomy::WorkflowContext)
203
+ Phronomy::Event.new(type: :action_completed, target_id: thread_id, payload: task_result)
204
+ else
205
+ Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
206
+ end
207
+ Phronomy::EventLoop.instance.post(ev)
208
+ rescue => e
209
+ Phronomy::EventLoop.instance.post(
210
+ Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
211
+ )
212
+ end
213
+ end
214
+
215
+ # Handles a +Phronomy::Task+ return value in non-EventLoop mode.
216
+ #
217
+ # Blocks the current execution context until the task completes or the
218
+ # optional timeout elapses.
219
+ #
220
+ # @param machine [Object] phase machine instance
221
+ # @param result [Phronomy::Task]
222
+ # @param state_name [Symbol]
223
+ # @param timeout_secs [Numeric, nil]
224
+ # @api private
225
+ def await_task_blocking(machine, result, state_name, timeout_secs)
226
+ enforce_timeout!(result, state_name, timeout_secs)
227
+ task_result = result.await
228
+ machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
229
+ end
230
+
231
+ # Raises +ActionTimeoutError+ if the task does not complete within
232
+ # +timeout_secs+. No-op when +timeout_secs+ is +nil+.
233
+ #
234
+ # @param result [Phronomy::Task]
235
+ # @param state_name [Symbol]
236
+ # @param timeout_secs [Numeric, nil]
237
+ # @api private
238
+ def enforce_timeout!(result, state_name, timeout_secs)
239
+ return unless timeout_secs
240
+ return unless result.join(timeout_secs).nil?
241
+
242
+ result.cancel!
243
+ raise Phronomy::ActionTimeoutError,
244
+ "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -135,14 +135,14 @@ module Phronomy
135
135
  # Run before_completion hooks before each LLM call in the ReAct loop.
136
136
  run_before_completion_hooks!(chat, config)
137
137
 
138
- response = if user_asked
139
- # Subsequent loop iteration history already contains the user message;
140
- # just ask the LLM to continue (e.g. after a tool result).
141
- chat.complete
142
- else
143
- # First iteration — add the new user question and call the LLM.
144
- chat.ask(extract_message(initial_input))
145
- end
138
+ # Route the LLM call through the configured LLMAdapter so that the
139
+ # blocking HTTP request runs inside BlockingAdapterPool and the
140
+ # adapter can be swapped without changing agent code.
141
+ # Passing nil as message signals the adapter to call chat.complete
142
+ # (no new user turn) for continuation iterations.
143
+ adapter = Phronomy.configuration.llm_adapter
144
+ message = user_asked ? nil : extract_message(initial_input)
145
+ response = adapter.complete_async(chat, message, config: config).await
146
146
 
147
147
  usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
148
148
  tool_calls = chat.messages.last&.tool_calls
@@ -181,13 +181,18 @@ module Phronomy
181
181
  # Run before_completion hooks before each LLM call in the streaming loop.
182
182
  run_before_completion_hooks!(chat, config)
183
183
 
184
+ # Route the streaming LLM call through the configured LLMAdapter so that
185
+ # the blocking HTTP request runs inside BlockingAdapterPool.
186
+ # Passing nil as message signals the adapter to call chat.complete
187
+ # (no new user turn) for continuation iterations.
188
+ # Streaming chunks and tool event callbacks are delivered directly via the
189
+ # block on the pool worker thread; pending.await yields cooperatively until
190
+ # streaming is complete.
191
+ adapter = Phronomy.configuration.llm_adapter
192
+ message = user_asked ? nil : extract_message(initial_input)
184
193
  streaming_block = proc { |chunk| block.call(StreamEvent.new(type: :token, payload: {content: chunk.content})) }
185
-
186
- response = if user_asked
187
- chat.complete(&streaming_block)
188
- else
189
- chat.ask(extract_message(initial_input), &streaming_block)
190
- end
194
+ pending = adapter.stream_async(chat, message, config: config, &streaming_block)
195
+ response = pending.await
191
196
 
192
197
  usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
193
198
  tool_calls = chat.messages.last&.tool_calls
@@ -74,7 +74,7 @@ module Phronomy
74
74
  def build_handoffs(routes)
75
75
  routes.each do |source_agent, target_agents|
76
76
  Array(target_agents).each do |target_agent|
77
- handoff = Handoff.new(target_agent: target_agent)
77
+ handoff = Phronomy::MultiAgent::Handoff.new(target_agent: target_agent)
78
78
  @sentinel_map[handoff.sentinel] = target_agent
79
79
  source_agent._add_handoff_tool(handoff.to_tool_class)
80
80
  end
@@ -86,7 +86,7 @@ module Phronomy
86
86
  next unless msg.role.to_sym == :tool
87
87
 
88
88
  content = msg.content.to_s
89
- next unless content.start_with?(Handoff::SENTINEL_PREFIX)
89
+ next unless content.start_with?(Phronomy::MultiAgent::Handoff::SENTINEL_PREFIX)
90
90
 
91
91
  return @sentinel_map[content]
92
92
  end