phronomy 0.7.1 → 0.9.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -45
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +11 -3
  6. data/benchmark/bench_regression.rb +11 -11
  7. data/benchmark/bench_token_estimator.rb +5 -5
  8. data/benchmark/bench_tool_schema.rb +2 -2
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +268 -403
  11. data/lib/phronomy/agent/checkpoint.rb +118 -0
  12. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  13. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  14. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  15. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  17. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  18. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  19. data/lib/phronomy/agent/fsm.rb +1 -1
  20. data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
  21. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  22. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  23. data/lib/phronomy/agent/react_agent.rb +43 -37
  24. data/lib/phronomy/agent/runner.rb +2 -2
  25. data/lib/phronomy/agent/shared_state.rb +2 -2
  26. data/lib/phronomy/agent/tool_executor.rb +108 -0
  27. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  28. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  29. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  30. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  31. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  32. data/lib/phronomy/concurrency/deadline.rb +65 -0
  33. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
  34. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  35. data/lib/phronomy/configuration.rb +0 -6
  36. data/lib/phronomy/context.rb +2 -8
  37. data/lib/phronomy/eval/runner.rb +4 -0
  38. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  39. data/lib/phronomy/event_loop.rb +7 -7
  40. data/lib/phronomy/invocation_context.rb +3 -3
  41. data/lib/phronomy/knowledge_source.rb +0 -5
  42. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  43. data/lib/phronomy/llm_context_window/assembler.rb +191 -0
  44. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  45. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  46. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  47. data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
  48. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
  49. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  50. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
  51. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  52. data/lib/phronomy/runtime.rb +20 -6
  53. data/lib/phronomy/task_group.rb +1 -1
  54. data/lib/phronomy/tool.rb +3 -4
  55. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  56. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  57. data/lib/phronomy/tools/vector_search.rb +70 -0
  58. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  59. data/lib/phronomy/vector_store/async_backend.rb +4 -4
  60. data/lib/phronomy/vector_store/base.rb +2 -2
  61. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  62. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  63. data/lib/phronomy/vector_store/in_memory.rb +12 -2
  64. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  65. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  66. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  67. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  68. data/lib/phronomy/vector_store/pgvector.rb +2 -2
  69. data/lib/phronomy/vector_store/redis_search.rb +2 -2
  70. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  71. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  72. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  73. data/lib/phronomy/vector_store.rb +14 -2
  74. data/lib/phronomy/version.rb +1 -1
  75. data/lib/phronomy/workflow_context.rb +8 -0
  76. data/lib/phronomy/workflow_runner.rb +11 -131
  77. data/lib/phronomy.rb +2 -0
  78. data/scripts/api_snapshot.rb +11 -9
  79. metadata +44 -46
  80. data/lib/phronomy/async_queue.rb +0 -155
  81. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  82. data/lib/phronomy/cancellation_scope.rb +0 -123
  83. data/lib/phronomy/cancellation_token.rb +0 -133
  84. data/lib/phronomy/concurrency_gate.rb +0 -155
  85. data/lib/phronomy/context/assembler.rb +0 -143
  86. data/lib/phronomy/context/compaction_context.rb +0 -111
  87. data/lib/phronomy/context/trigger_context.rb +0 -39
  88. data/lib/phronomy/context/trim_context.rb +0 -75
  89. data/lib/phronomy/deadline.rb +0 -63
  90. data/lib/phronomy/embeddings/base.rb +0 -39
  91. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  92. data/lib/phronomy/embeddings.rb +0 -11
  93. data/lib/phronomy/fsm_session.rb +0 -247
  94. data/lib/phronomy/knowledge_source/base.rb +0 -54
  95. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  96. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  97. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  98. data/lib/phronomy/loader/base.rb +0 -25
  99. data/lib/phronomy/loader/csv_loader.rb +0 -56
  100. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  101. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  102. data/lib/phronomy/loader.rb +0 -13
  103. data/lib/phronomy/prompt_template.rb +0 -96
  104. data/lib/phronomy/splitter/base.rb +0 -47
  105. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  106. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  107. data/lib/phronomy/splitter.rb +0 -12
  108. data/lib/phronomy/tool/base.rb +0 -644
  109. data/lib/phronomy/tool/scope_policy.rb +0 -50
  110. data/lib/phronomy/tool_executor.rb +0 -106
@@ -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
@@ -117,32 +117,30 @@ module Phronomy
117
117
  def step(messages, initial_input, user_asked: false, thread_id: nil, config: {})
118
118
  chat = build_chat
119
119
 
120
- if user_asked
121
- # Subsequent loop iteration — messages already contains the full conversation
122
- # (including the user's original input from the first step); apply system
123
- # instructions and replay the accumulated history, then let the LLM continue.
124
- system_text = build_cached_system_text(initial_input)
125
- apply_instructions(chat, system_text) if system_text
126
- messages.each { |m| chat.add_message(m) }
127
- else
128
- # First iteration — assemble context (system + history) via build_context so
129
- # that trimming, compaction, and knowledge sources are applied consistently.
130
- context = build_context(initial_input, messages: messages, thread_id: thread_id, config: config)
131
- apply_instructions(chat, context[:system]) if context[:system]
132
- context[:messages].each { |m| chat.messages << m }
133
- end
120
+ context = build_context(
121
+ initial_input,
122
+ messages: messages,
123
+ thread_id: thread_id,
124
+ config: config,
125
+ budget: build_token_budget,
126
+ instruction: build_instructions(initial_input),
127
+ tools: self.class.tools + _handoff_tools
128
+ )
129
+ apply_instructions(chat, context[:system]) if context[:system]
130
+ (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
131
+ context[:messages].each { |m| chat.add_message(m) }
134
132
 
135
133
  # Run before_completion hooks before each LLM call in the ReAct loop.
136
134
  run_before_completion_hooks!(chat, config)
137
135
 
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
136
+ # Route the LLM call through the configured LLMAdapter so that the
137
+ # blocking HTTP request runs inside BlockingAdapterPool and the
138
+ # adapter can be swapped without changing agent code.
139
+ # Passing nil as message signals the adapter to call chat.complete
140
+ # (no new user turn) for continuation iterations.
141
+ adapter = Phronomy.configuration.llm_adapter
142
+ message = user_asked ? nil : extract_message(initial_input)
143
+ response = adapter.complete_async(chat, message, config: config).await
146
144
 
147
145
  usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
148
146
  tool_calls = chat.messages.last&.tool_calls
@@ -155,15 +153,18 @@ module Phronomy
155
153
  def stream_step(messages, initial_input, user_asked: false, thread_id: nil, config: {}, &block)
156
154
  chat = build_chat
157
155
 
158
- if user_asked
159
- system_text = build_cached_system_text(initial_input)
160
- apply_instructions(chat, system_text) if system_text
161
- messages.each { |m| chat.add_message(m) }
162
- else
163
- context = build_context(initial_input, messages: messages, thread_id: thread_id, config: config)
164
- apply_instructions(chat, context[:system]) if context[:system]
165
- context[:messages].each { |m| chat.messages << m }
166
- end
156
+ context = build_context(
157
+ initial_input,
158
+ messages: messages,
159
+ thread_id: thread_id,
160
+ config: config,
161
+ budget: build_token_budget,
162
+ instruction: build_instructions(initial_input),
163
+ tools: self.class.tools + _handoff_tools
164
+ )
165
+ apply_instructions(chat, context[:system]) if context[:system]
166
+ (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
167
+ context[:messages].each { |m| chat.add_message(m) }
167
168
 
168
169
  current_tool_call = nil
169
170
  chat.on_tool_call do |tc|
@@ -181,13 +182,18 @@ module Phronomy
181
182
  # Run before_completion hooks before each LLM call in the streaming loop.
182
183
  run_before_completion_hooks!(chat, config)
183
184
 
185
+ # Route the streaming LLM call through the configured LLMAdapter so that
186
+ # the blocking HTTP request runs inside BlockingAdapterPool.
187
+ # Passing nil as message signals the adapter to call chat.complete
188
+ # (no new user turn) for continuation iterations.
189
+ # Streaming chunks and tool event callbacks are delivered directly via the
190
+ # block on the pool worker thread; pending.await yields cooperatively until
191
+ # streaming is complete.
192
+ adapter = Phronomy.configuration.llm_adapter
193
+ message = user_asked ? nil : extract_message(initial_input)
184
194
  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
195
+ pending = adapter.stream_async(chat, message, config: config, &streaming_block)
196
+ response = pending.await
191
197
 
192
198
  usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
193
199
  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
@@ -239,7 +239,7 @@ module Phronomy
239
239
  def build_instrumented_researcher(researcher_class, store, cycle)
240
240
  agent_key = researcher_class.name&.to_sym || researcher_class.object_id.to_s.to_sym
241
241
 
242
- read_tool = Class.new(Phronomy::Tool::Base) do
242
+ read_tool = Class.new(Phronomy::Agent::Context::Capability::Base) do
243
243
  tool_name "read_store"
244
244
  description "Read all current findings from the shared knowledge store. " \
245
245
  "Call this to see what other researchers have discovered."
@@ -247,7 +247,7 @@ module Phronomy
247
247
  define_method(:execute) { store.read_all.to_json }
248
248
  end
249
249
 
250
- write_tool = Class.new(Phronomy::Tool::Base) do
250
+ write_tool = Class.new(Phronomy::Agent::Context::Capability::Base) do
251
251
  tool_name "write_finding"
252
252
  description "Record a new finding into the shared knowledge store so " \
253
253
  "that other researchers can build on your discovery."