phronomy 0.9.0 → 0.9.1

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.
@@ -1,249 +0,0 @@
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
@@ -1,205 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # ReAct pattern (Reasoning + Acting) agent.
6
- # Repeats the LLM <-> Tool loop until no more tool calls are made.
7
- class ReactAgent < Base
8
- private
9
-
10
- # Performs a single (non-retried) ReAct invocation.
11
- # Overrides Base#invoke_once so that Base#invoke's retry loop is inherited.
12
- def invoke_once(input, messages: [], thread_id: nil, config: {})
13
- caller_meta = {}
14
- caller_meta[:user_id] = config[:user_id] if config[:user_id]
15
- caller_meta[:session_id] = config[:session_id] if config[:session_id]
16
- if (ic = config[:invocation_context])
17
- caller_meta[:task_id] = ic.task_id if ic.task_id
18
- caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
19
- end
20
-
21
- trace("agent.invoke", input: input, **caller_meta) do |_span|
22
- # Run input guardrails before any LLM interaction.
23
- run_input_guardrails!(input)
24
-
25
- max_iter = self.class.max_iterations
26
-
27
- # Seed with app-managed conversation history when provided.
28
- messages = Array(messages).dup
29
- user_asked = false
30
- total_usage = Phronomy::TokenUsage.zero
31
- iterations_exhausted = true
32
-
33
- max_iter.times do
34
- response = step(messages, input, user_asked: user_asked, thread_id: thread_id, config: config)
35
- user_asked = true
36
- messages = response[:messages]
37
- total_usage += response[:usage]
38
- if response[:done]
39
- iterations_exhausted = false
40
- break
41
- end
42
- end
43
-
44
- # Select the last assistant-produced content as the output, skipping
45
- # raw tool result messages (role: :tool) to avoid returning tool JSON
46
- # or status strings as the agent's answer when iterations are exhausted.
47
- output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
48
-
49
- # Run output guardrails before returning to the caller.
50
- run_output_guardrails!(output)
51
-
52
- result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
53
- [result, total_usage]
54
- end
55
- end
56
-
57
- public
58
-
59
- # Streaming version of #invoke for the ReAct loop.
60
- # Yields {Phronomy::Agent::StreamEvent} events while the LLM-tool loop runs.
61
- #
62
- # @param input [String, Hash]
63
- # @param messages [Array<RubyLLM::Message>] same as #invoke
64
- # @param thread_id [String, nil] same as #invoke
65
- # @param config [Hash]
66
- # @yield [Phronomy::Agent::StreamEvent]
67
- # @return [Hash] { output:, messages:, usage: }
68
- # @api public
69
- def stream(input, messages: [], thread_id: nil, config: {}, &block)
70
- return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
71
-
72
- caller_meta = {}
73
- caller_meta[:user_id] = config[:user_id] if config[:user_id]
74
- caller_meta[:session_id] = config[:session_id] if config[:session_id]
75
- if (ic = config[:invocation_context])
76
- caller_meta[:task_id] = ic.task_id if ic.task_id
77
- caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
78
- end
79
-
80
- trace("agent.invoke", input: input, **caller_meta) do |_span|
81
- run_input_guardrails!(input)
82
-
83
- max_iter = self.class.max_iterations
84
-
85
- messages = Array(messages).dup
86
- user_asked = false
87
- total_usage = Phronomy::TokenUsage.zero
88
- iterations_exhausted = true
89
-
90
- max_iter.times do
91
- response = stream_step(messages, input, user_asked: user_asked, thread_id: thread_id, config: config, &block)
92
- user_asked = true
93
- messages = response[:messages]
94
- total_usage += response[:usage]
95
- if response[:done]
96
- iterations_exhausted = false
97
- break
98
- end
99
- end
100
-
101
- # Select the last assistant-produced content as the output, skipping
102
- # raw tool result messages (role: :tool) — same as the non-streaming path.
103
- output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
104
- run_output_guardrails!(output)
105
-
106
- result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
107
- block.call(StreamEvent.new(type: :done, payload: result))
108
- [result, total_usage]
109
- end
110
- rescue => e
111
- block&.call(StreamEvent.new(type: :error, payload: {error: e}))
112
- raise
113
- end
114
-
115
- private
116
-
117
- def step(messages, initial_input, user_asked: false, thread_id: nil, config: {})
118
- chat = build_chat
119
-
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) }
132
-
133
- # Run before_completion hooks before each LLM call in the ReAct loop.
134
- run_before_completion_hooks!(chat, config)
135
-
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
144
-
145
- usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
146
- tool_calls = chat.messages.last&.tool_calls
147
- done = tool_calls.nil? || tool_calls.empty?
148
- {messages: chat.messages, done: done, usage: usage}
149
- end
150
-
151
- # Streaming variant of #step. Yields :token / :tool_call / :tool_result events
152
- # via the block while the LLM call is in progress.
153
- def stream_step(messages, initial_input, user_asked: false, thread_id: nil, config: {}, &block)
154
- chat = build_chat
155
-
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) }
168
-
169
- current_tool_call = nil
170
- chat.on_tool_call do |tc|
171
- current_tool_call = tc
172
- block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc}))
173
- end
174
- chat.on_tool_result do |tr|
175
- block.call(StreamEvent.new(type: :tool_result, payload: {
176
- tool_call_id: current_tool_call&.id,
177
- tool_name: current_tool_call&.name,
178
- tool_result: tr
179
- }))
180
- end
181
-
182
- # Run before_completion hooks before each LLM call in the streaming loop.
183
- run_before_completion_hooks!(chat, config)
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)
194
- streaming_block = proc { |chunk| block.call(StreamEvent.new(type: :token, payload: {content: chunk.content})) }
195
- pending = adapter.stream_async(chat, message, config: config, &streaming_block)
196
- response = pending.await
197
-
198
- usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
199
- tool_calls = chat.messages.last&.tool_calls
200
- done = tool_calls.nil? || tool_calls.empty?
201
- {messages: chat.messages, done: done, usage: usage}
202
- end
203
- end
204
- end
205
- end