phronomy 0.9.0 → 0.10.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.
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "state_machines"
4
+
5
+ module Phronomy
6
+ class Workflow
7
+ # Builds the anonymous state-machine Class used by {WorkflowRunner} to track
8
+ # workflow phase transitions.
9
+ #
10
+ # Extracted from {WorkflowRunner#build_phase_machine_class} to reduce the
11
+ # span of WorkflowRunner's initializer and to give the FSM construction
12
+ # logic an explicit, testable home.
13
+ #
14
+ # Call {#build} to obtain the generated +Class+. The returned class responds
15
+ # to +#context+ / +#context=+ and +#async_pending+ / +#async_pending=+, and
16
+ # has a +state_machine :phase+ definition with all registered transitions and
17
+ # callbacks.
18
+ #
19
+ # @api private
20
+ class PhaseMachineBuilder
21
+ # @param entry_point [Symbol] initial state for the phase machine
22
+ # @param declared_states [Array<Symbol>] all states declared in the workflow
23
+ # @param wait_state_names [Array<Symbol>] states that wait for external events
24
+ # @param external_events [Hash{Symbol => Array<Hash>}]
25
+ # +{ event_name => [{from:, to:, guard:}, ...] }+
26
+ # @param entry_actions [Hash{Symbol => Array<#call>}]
27
+ # +{ state_name => [callable, ...] }+
28
+ # @param action_timeouts [Hash{Symbol => Numeric}]
29
+ # +{ state_name => seconds }+
30
+ # @param auto_transitions [Array<Hash>]
31
+ # +[{ from:, to:, guard: }, ...]+ — all auto-fire transitions
32
+ # @param exit_actions [Hash{Symbol => Array<#call>}]
33
+ # +{ state_name => [callable, ...] }+
34
+ # @api private
35
+ def initialize(
36
+ entry_point:,
37
+ declared_states:,
38
+ wait_state_names:,
39
+ external_events:,
40
+ entry_actions:,
41
+ action_timeouts:,
42
+ auto_transitions:,
43
+ exit_actions:
44
+ )
45
+ @entry_point = entry_point
46
+ @declared_states = declared_states
47
+ @wait_state_names = wait_state_names
48
+ @external_events = external_events
49
+ @entry_actions = entry_actions
50
+ @action_timeouts = action_timeouts
51
+ @auto_transitions = auto_transitions
52
+ @exit_actions = exit_actions
53
+ end
54
+
55
+ # Constructs and returns the anonymous phase-machine Class.
56
+ #
57
+ # @return [Class] an anonymous class with a +state_machine :phase+ definition
58
+ # @raise [ArgumentError] if state_machines raises during class construction
59
+ # @api private
60
+ def build
61
+ entry = @entry_point
62
+ all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
63
+ auto_trans = @auto_transitions
64
+ ext_events = @external_events
65
+ entry_acts = @entry_actions
66
+ exit_acts = @exit_actions
67
+ act_timeouts = @action_timeouts
68
+ build_cb = method(:build_entry_callback)
69
+
70
+ Class.new do
71
+ # Holds the current WorkflowContext so guards and callbacks can read it.
72
+ attr_accessor :context
73
+
74
+ # Set to true by an entry action that returned an awaitable Task.
75
+ # When true, FSMSession skips the automatic advance_or_halt step and
76
+ # waits for the async worker thread to post a state_completed event back.
77
+ attr_accessor :async_pending
78
+
79
+ state_machine :phase, initial: entry do
80
+ all_states.each { |s| state s }
81
+
82
+ # Auto-fire transitions: all auto transitions unified under :state_completed.
83
+ # Includes unguarded (unconditional) and guarded (conditional) transitions.
84
+ # Declaration order is preserved; guards are evaluated before unguarded fallbacks.
85
+ event :state_completed do
86
+ auto_trans.each do |t|
87
+ if t[:guard]
88
+ guard_proc = t[:guard]
89
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
90
+ else
91
+ transition t[:from] => t[:to]
92
+ end
93
+ end
94
+ end
95
+
96
+ # External events: human-in-the-loop triggers from wait states.
97
+ ext_events.each do |ev_name, transitions|
98
+ event ev_name do
99
+ transitions.each do |t|
100
+ if t[:guard]
101
+ guard_proc = t[:guard]
102
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
103
+ else
104
+ transition t[:from] => t[:to]
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # Entry callbacks: fire after_transition into each state.
111
+ # Each callable is registered as a separate callback; state_machines
112
+ # accumulates them and fires in declaration order.
113
+ # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
114
+ # the returned context replaces the current one on the tracker.
115
+ entry_acts.each do |state_name, callables|
116
+ callables.each do |callable|
117
+ cb = build_cb.call(callable, state_name, act_timeouts[state_name])
118
+ after_transition to: state_name, &cb
119
+ end
120
+ end
121
+
122
+ # Exit callbacks: fire before_transition out of each state.
123
+ # Each callable is registered as a separate callback; state_machines
124
+ # accumulates them and fires in declaration order.
125
+ exit_acts.each do |state_name, callables|
126
+ callables.each do |callable|
127
+ before_transition from: state_name do |machine|
128
+ callable.call(machine.context)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ rescue => e
135
+ raise ArgumentError, "Failed to build phase machine: #{e.message}"
136
+ end
137
+
138
+ private
139
+
140
+ # Returns a proc suitable for use as an +after_transition+ callback.
141
+ #
142
+ # The returned proc accepts a single argument (the phase machine instance),
143
+ # invokes the entry action callable with the current context, then delegates
144
+ # the result to {#handle_entry_action_result}. Capturing this in the
145
+ # builder's scope lets the anonymous +Class.new+ block stay slim.
146
+ #
147
+ # @param callable [#call] the entry action
148
+ # @param state_name [Symbol] name of the target state (for error messages)
149
+ # @param timeout_secs [Numeric, nil] seconds before ActionTimeoutError
150
+ # @return [Proc]
151
+ # @api private
152
+ def build_entry_callback(callable, state_name, timeout_secs)
153
+ handle = method(:handle_entry_action_result)
154
+ ->(machine) {
155
+ result = callable.call(machine.context)
156
+ handle.call(machine, result, state_name, timeout_secs)
157
+ }
158
+ end
159
+
160
+ # Dispatches the return value of an entry action callable.
161
+ #
162
+ # - +Phronomy::Task+ → async or blocking task handling
163
+ # - +Phronomy::WorkflowContext+ → replaces the machine's context directly
164
+ # - anything else → ignored
165
+ #
166
+ # @param machine [Object] phase machine instance
167
+ # @param result [Object] return value of the entry callable
168
+ # @param state_name [Symbol] name of the entered state
169
+ # @param timeout_secs [Numeric, nil] optional timeout in seconds
170
+ # @api private
171
+ def handle_entry_action_result(machine, result, state_name, timeout_secs)
172
+ if result.is_a?(Phronomy::Task)
173
+ if Phronomy.configuration.event_loop
174
+ dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
175
+ else
176
+ await_task_blocking(machine, result, state_name, timeout_secs)
177
+ end
178
+ elsif result.is_a?(Phronomy::WorkflowContext)
179
+ machine.context = result
180
+ end
181
+ end
182
+
183
+ # Handles a +Phronomy::Task+ return value in EventLoop mode.
184
+ #
185
+ # Marks the machine as async-pending and spawns a cooperative background
186
+ # task that awaits the result, then posts the appropriate event back to
187
+ # the EventLoop. +FSMSession+ will skip the automatic +advance_or_halt+
188
+ # step while +async_pending+ is true.
189
+ #
190
+ # @param machine [Object] phase machine instance
191
+ # @param result [Phronomy::Task]
192
+ # @param state_name [Symbol]
193
+ # @param timeout_secs [Numeric, nil]
194
+ # @api private
195
+ def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
196
+ machine.async_pending = true
197
+ thread_id = machine.context.thread_id
198
+ Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
199
+ enforce_timeout!(result, state_name, timeout_secs)
200
+ task_result = result.await
201
+ ev = if task_result.is_a?(Phronomy::WorkflowContext)
202
+ Phronomy::Event.new(type: :action_completed, target_id: thread_id, payload: task_result)
203
+ else
204
+ Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
205
+ end
206
+ Phronomy::EventLoop.instance.post(ev)
207
+ rescue => e
208
+ Phronomy::EventLoop.instance.post(
209
+ Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
210
+ )
211
+ end
212
+ end
213
+
214
+ # Handles a +Phronomy::Task+ return value in non-EventLoop mode.
215
+ #
216
+ # Blocks the current execution context until the task completes or the
217
+ # optional timeout elapses.
218
+ #
219
+ # @param machine [Object] phase machine instance
220
+ # @param result [Phronomy::Task]
221
+ # @param state_name [Symbol]
222
+ # @param timeout_secs [Numeric, nil]
223
+ # @api private
224
+ def await_task_blocking(machine, result, state_name, timeout_secs)
225
+ enforce_timeout!(result, state_name, timeout_secs)
226
+ task_result = result.await
227
+ machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
228
+ end
229
+
230
+ # Raises +ActionTimeoutError+ if the task does not complete within
231
+ # +timeout_secs+. No-op when +timeout_secs+ is +nil+.
232
+ #
233
+ # @param result [Phronomy::Task]
234
+ # @param state_name [Symbol]
235
+ # @param timeout_secs [Numeric, nil]
236
+ # @api private
237
+ def enforce_timeout!(result, state_name, timeout_secs)
238
+ return unless timeout_secs
239
+ return unless result.join(timeout_secs).nil?
240
+
241
+ result.cancel!
242
+ raise Phronomy::ActionTimeoutError,
243
+ "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
244
+ end
245
+ end
246
+ end
247
+ end
@@ -56,7 +56,7 @@ module Phronomy
56
56
  @wait_state_names = wait_state_names
57
57
  @state_store = state_store
58
58
  @action_timeouts = action_timeouts # { state_name => seconds }
59
- @phase_machine_class = Agent::Lifecycle::PhaseMachineBuilder.new(
59
+ @phase_machine_class = Workflow::PhaseMachineBuilder.new(
60
60
  entry_point: @entry_point,
61
61
  declared_states: @declared_states,
62
62
  wait_state_names: @wait_state_names,
@@ -169,7 +169,7 @@ module Phronomy
169
169
 
170
170
  # Builds an FSMSession for the given context. Used in EventLoop mode.
171
171
  def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
172
- Phronomy::Agent::Lifecycle::FSMSession.new(
172
+ Phronomy::Workflow::FSMSession.new(
173
173
  id: context.thread_id,
174
174
  context: context,
175
175
  entry_point: @entry_point,
data/lib/phronomy.rb CHANGED
@@ -12,8 +12,6 @@ loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
12
12
  loader.inflector.inflect("rag" => "RAG")
13
13
  # FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
14
14
  loader.inflector.inflect("fsm_session" => "FSMSession")
15
- # AgentFSM: Zeitwerk would infer "Fsm" — override to "FSM".
16
- loader.inflector.inflect("fsm" => "FSM")
17
15
  # LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
18
16
  loader.inflector.inflect("llm_adapter" => "LLMAdapter")
19
17
  # LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
@@ -98,6 +96,14 @@ module Phronomy
98
96
  end
99
97
  end
100
98
 
99
+ # Raised when {Agent::Base#resume} (or the class-level equivalent) is called
100
+ # with a {Agent::Checkpoint} whose +checkpoint_id+ has already been consumed
101
+ # by a previous +resume+ call on the same store.
102
+ #
103
+ # This protects against duplicate resume executions caused by webhook retries
104
+ # or queue message redelivery.
105
+ class CheckpointAlreadyResumedError < Error; end
106
+
101
107
  # Raised when an operation is submitted to a {BlockingAdapterPool} that has
102
108
  # already been shut down via {BlockingAdapterPool#shutdown}.
103
109
  class PoolShutdownError < Error; end
@@ -117,7 +123,8 @@ module Phronomy
117
123
  # Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
118
124
  # that does not own the context (i.e. not the EventLoop dispatch thread).
119
125
  # Only raised in EventLoop mode. Use +context.merge(...)+ to produce a new
120
- # context, or deliver updates as +:child_completed+ event payloads.
126
+ # context, or deliver updates as +:action_completed+ event payloads
127
+ # via {Agent::Base#invoke_async} + {Task#map}.
121
128
  class WorkflowContextOwnershipError < Error; end
122
129
 
123
130
  class << self
@@ -30,7 +30,6 @@ PUBLIC_API_ENTRIES = [
30
30
  Phronomy::Runnable,
31
31
  Phronomy::Agent::Context::Instruction::PromptTemplate,
32
32
  # Beta
33
- Phronomy::Agent::ReactAgent,
34
33
  Phronomy::MultiAgent::Orchestrator,
35
34
  Phronomy::MultiAgent::TeamCoordinator,
36
35
  Phronomy::Guardrail::InputGuardrail,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-01 00:00:00.000000000 Z
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -106,6 +106,7 @@ files:
106
106
  - lib/phronomy/agent/base.rb
107
107
  - lib/phronomy/agent/before_completion_context.rb
108
108
  - lib/phronomy/agent/checkpoint.rb
109
+ - lib/phronomy/agent/checkpoint_store.rb
109
110
  - lib/phronomy/agent/concerns/before_completion.rb
110
111
  - lib/phronomy/agent/concerns/error_translation.rb
111
112
  - lib/phronomy/agent/concerns/guardrailable.rb
@@ -117,11 +118,6 @@ files:
117
118
  - lib/phronomy/agent/context/knowledge/base.rb
118
119
  - lib/phronomy/agent/context/knowledge/entity_knowledge.rb
119
120
  - lib/phronomy/agent/context/knowledge/static_knowledge.rb
120
- - lib/phronomy/agent/fsm.rb
121
- - lib/phronomy/agent/invocation_pipeline.rb
122
- - lib/phronomy/agent/lifecycle/fsm_session.rb
123
- - lib/phronomy/agent/lifecycle/phase_machine_builder.rb
124
- - lib/phronomy/agent/react_agent.rb
125
121
  - lib/phronomy/agent/runner.rb
126
122
  - lib/phronomy/agent/shared_state.rb
127
123
  - lib/phronomy/agent/suspend_signal.rb
@@ -193,6 +189,7 @@ files:
193
189
  - lib/phronomy/task/backend.rb
194
190
  - lib/phronomy/task/fiber_backend.rb
195
191
  - lib/phronomy/task/immediate_backend.rb
192
+ - lib/phronomy/task/mapped_backend.rb
196
193
  - lib/phronomy/task/thread_backend.rb
197
194
  - lib/phronomy/task_group.rb
198
195
  - lib/phronomy/testing.rb
@@ -226,6 +223,8 @@ files:
226
223
  - lib/phronomy/vector_store/splitter/recursive_splitter.rb
227
224
  - lib/phronomy/version.rb
228
225
  - lib/phronomy/workflow.rb
226
+ - lib/phronomy/workflow/fsm_session.rb
227
+ - lib/phronomy/workflow/phase_machine_builder.rb
229
228
  - lib/phronomy/workflow_context.rb
230
229
  - lib/phronomy/workflow_runner.rb
231
230
  - scripts/api_snapshot.rb
@@ -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