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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -4
- data/README.md +1 -0
- data/lib/phronomy/agent/base.rb +118 -58
- data/lib/phronomy/agent/checkpoint.rb +30 -1
- data/lib/phronomy/agent/checkpoint_store.rb +97 -0
- data/lib/phronomy/agent/concerns/retryable.rb +1 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +57 -2
- data/lib/phronomy/configuration.rb +13 -0
- data/lib/phronomy/event_loop.rb +1 -18
- data/lib/phronomy/tools/agent.rb +2 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/fsm_session.rb +249 -0
- data/lib/phronomy/workflow/phase_machine_builder.rb +247 -0
- data/lib/phronomy/workflow_runner.rb +2 -2
- data/lib/phronomy.rb +8 -2
- data/scripts/api_snapshot.rb +0 -1
- metadata +5 -7
- data/lib/phronomy/agent/fsm.rb +0 -157
- data/lib/phronomy/agent/invocation_pipeline.rb +0 -108
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +0 -251
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +0 -249
- data/lib/phronomy/agent/react_agent.rb +0 -205
|
@@ -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
|