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,157 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Phronomy
6
- module Agent
7
- # EventLoop-registered execution unit for a single agent invocation.
8
- #
9
- # +AgentFSM+ implements the minimal interface expected by {Phronomy::EventLoop}
10
- # (+#id+, +#start+, +#handle+) so it can be managed alongside
11
- # {Phronomy::Agent::Lifecycle::FSMSession} instances. It is *not* a traditional finite-state
12
- # machine; the name reflects its role in the EventLoop rather than internal
13
- # state transitions.
14
- #
15
- # == Execution model
16
- #
17
- # {#start} is called by the EventLoop on the +:start+ event. It immediately
18
- # returns after spawning a {Phronomy::Task} that runs the agent's full
19
- # invocation pipeline (via +_invoke_impl+). The EventLoop thread is never
20
- # blocked by agent execution.
21
- #
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
- #
27
- # == Completion events
28
- #
29
- # On *success*:
30
- # - Posts +:finished+ to this FSM's own +#id+ so the EventLoop cleans up
31
- # its registry entry and unblocks any +completion_queue.pop+ caller.
32
- # - When +parent_id+ is set (child-FSM pattern), additionally posts
33
- # +:child_completed+ to +parent_id+, carrying the result hash as the
34
- # event payload. The parent {FSMSession} must declare an +on:+ transition
35
- # for +:child_completed+ to advance correctly.
36
- #
37
- # On *error*:
38
- # - Posts +:error+ to this FSM's own +#id+. The EventLoop propagates the
39
- # exception through the +completion_queue+ so that the original caller of
40
- # +Agent::Base#invoke+ (in EventLoop mode) receives and re-raises it.
41
- #
42
- # == Standalone usage (blocking caller)
43
- #
44
- # Phronomy.configure { |c| c.event_loop = true }
45
- # result = MyAgent.new.invoke("Hello!") # => { output:, messages:, usage: }
46
- #
47
- # {Agent::Base#invoke} detects EventLoop mode, creates an +AgentFSM+, registers
48
- # it via {EventLoop#register}, and blocks the *calling* thread on the returned
49
- # +completion_queue+ until the agent finishes.
50
- #
51
- # == Child-FSM usage (non-blocking, inside a Workflow)
52
- #
53
- # state :run_agent
54
- # entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
55
- # transition from: :run_agent, on: :child_completed, to: :process_result
56
- #
57
- # {Agent::Base#run_as_child} creates an +AgentFSM+ with +parent_id+ set to
58
- # +ctx.thread_id+, registers it with the EventLoop, and returns immediately.
59
- # The parent {FSMSession} waits for the +:child_completed+ event.
60
- # @api private
61
- class FSM
62
- # @return [String] unique identifier used as the EventLoop target_id
63
- attr_reader :id
64
-
65
- # @return [Symbol] current internal phase (:idle, :running)
66
- attr_reader :current_phase
67
-
68
- # @param agent [Phronomy::Agent::Base] agent instance to run
69
- # @param input [String, Hash] user input passed to +invoke_once+
70
- # @param messages [Array] prior conversation history
71
- # @param thread_id [String, nil] conversation thread id;
72
- # auto-generated when nil
73
- # @param config [Hash] invocation config forwarded to
74
- # +_invoke_impl+
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.
81
- #
82
- # @api private
83
- def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil)
84
- @agent = agent
85
- @input = input
86
- @messages = Array(messages).dup
87
- @thread_id = thread_id || SecureRandom.uuid
88
- @config = config
89
- @parent_id = parent_id
90
- @id = @thread_id
91
- @current_phase = :idle
92
- end
93
-
94
- # Called by {EventLoop} on the +:start+ event.
95
- # Transitions to +:running+ and spawns the agent task.
96
- def start
97
- @current_phase = :running
98
- spawn_agent_task
99
- end
100
-
101
- # Called by {EventLoop} for external events dispatched to this id.
102
- # +AgentFSM+ is fully driven by its own IO thread and does not respond
103
- # to external events after {#start}.
104
- def handle(_event)
105
- # No-op: AgentFSM is driven entirely by its IO thread.
106
- end
107
-
108
- private
109
-
110
- # Spawns a {Phronomy::Task} that runs the agent invocation pipeline.
111
- # Captures all instance variables by value so the task closure is
112
- # safe even if the FSM object is modified (though it is not in practice).
113
- def spawn_agent_task
114
- agent = @agent
115
- input = @input
116
- messages = @messages
117
- thread_id = @thread_id
118
- config = @config
119
- fsm_id = @id
120
- parent_id = @parent_id
121
-
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)
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.
133
- Phronomy::EventLoop.instance.post(
134
- Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
135
- )
136
- end
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
143
- Phronomy::EventLoop.instance.post(
144
- Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
145
- )
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.
153
- end
154
- end
155
- end
156
- end
157
- end
@@ -1,108 +0,0 @@
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(
47
- inp,
48
- messages: msgs,
49
- thread_id: tid,
50
- config: cfg,
51
- budget: build_token_budget,
52
- instruction: build_instructions(inp),
53
- tools: self.class.tools + _handoff_tools
54
- )
55
- apply_instructions(chat, context[:system]) if context[:system]
56
- (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
57
- context[:messages].each { |msg| chat.messages << msg }
58
-
59
- # Run before_completion hooks (global → class → instance) before the LLM call.
60
- run_before_completion_hooks!(chat, cfg)
61
-
62
- # Register suspension hook for approval-required tools (no-op when a
63
- # synchronous on_approval_required handler is already registered).
64
- _register_suspension_hook!(chat)
65
-
66
- # Check for cancellation immediately before the LLM call.
67
- check_cancellation!(cfg, "invocation cancelled before LLM call")
68
-
69
- # Forward the cancellation token to ParallelToolChat explicitly
70
- # via the chat instance so that tool dispatch batches can observe
71
- # cancellation without needing Thread.current.
72
- chat.cancellation_token = cfg[:cancellation_token] if chat.respond_to?(:cancellation_token=)
73
-
74
- begin
75
- # Route the LLM call through the configured LLMAdapter so that the
76
- # blocking HTTP request runs inside BlockingAdapterPool and the
77
- # adapter can be swapped without changing agent code.
78
- adapter = Phronomy.configuration.llm_adapter
79
- response = adapter.complete_async(chat, user_message, config: cfg).await
80
- rescue SuspendSignal => signal
81
- checkpoint = Checkpoint.new(
82
- thread_id: tid,
83
- original_input: inp,
84
- messages: chat.messages.dup,
85
- pending_tool_name: signal.tool_name,
86
- pending_tool_args: signal.args,
87
- pending_tool_call_id: signal.tool_call_id
88
- )
89
- suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
90
- next [suspended_result, nil]
91
- ensure
92
- # Clear the chat's cancellation token reference after each LLM call.
93
- chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
94
- end
95
-
96
- output = response.content
97
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
98
-
99
- # Run output guardrails before returning to the caller.
100
- run_output_guardrails!(output)
101
-
102
- result = {output: output, messages: chat.messages, usage: usage}
103
- [result, usage]
104
- end
105
- end
106
- end
107
- end
108
- end
@@ -1,251 +0,0 @@
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