phronomy 0.7.0 → 0.8.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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -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
|
|
@@ -13,6 +13,10 @@ module Phronomy
|
|
|
13
13
|
caller_meta = {}
|
|
14
14
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
15
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
|
|
16
20
|
|
|
17
21
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
18
22
|
# Run input guardrails before any LLM interaction.
|
|
@@ -68,6 +72,10 @@ module Phronomy
|
|
|
68
72
|
caller_meta = {}
|
|
69
73
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
70
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
|
|
71
79
|
|
|
72
80
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
73
81
|
run_input_guardrails!(input)
|
|
@@ -127,14 +135,14 @@ module Phronomy
|
|
|
127
135
|
# Run before_completion hooks before each LLM call in the ReAct loop.
|
|
128
136
|
run_before_completion_hooks!(chat, config)
|
|
129
137
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
# Route the LLM call through the configured LLMAdapter so that the
|
|
139
|
+
# blocking HTTP request runs inside BlockingAdapterPool and the
|
|
140
|
+
# adapter can be swapped without changing agent code.
|
|
141
|
+
# Passing nil as message signals the adapter to call chat.complete
|
|
142
|
+
# (no new user turn) for continuation iterations.
|
|
143
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
144
|
+
message = user_asked ? nil : extract_message(initial_input)
|
|
145
|
+
response = adapter.complete_async(chat, message, config: config).await
|
|
138
146
|
|
|
139
147
|
usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
|
|
140
148
|
tool_calls = chat.messages.last&.tool_calls
|
|
@@ -173,13 +181,18 @@ module Phronomy
|
|
|
173
181
|
# Run before_completion hooks before each LLM call in the streaming loop.
|
|
174
182
|
run_before_completion_hooks!(chat, config)
|
|
175
183
|
|
|
184
|
+
# Route the streaming LLM call through the configured LLMAdapter so that
|
|
185
|
+
# the blocking HTTP request runs inside BlockingAdapterPool.
|
|
186
|
+
# Passing nil as message signals the adapter to call chat.complete
|
|
187
|
+
# (no new user turn) for continuation iterations.
|
|
188
|
+
# Streaming chunks and tool event callbacks are delivered directly via the
|
|
189
|
+
# block on the pool worker thread; pending.await yields cooperatively until
|
|
190
|
+
# streaming is complete.
|
|
191
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
192
|
+
message = user_asked ? nil : extract_message(initial_input)
|
|
176
193
|
streaming_block = proc { |chunk| block.call(StreamEvent.new(type: :token, payload: {content: chunk.content})) }
|
|
177
|
-
|
|
178
|
-
response =
|
|
179
|
-
chat.complete(&streaming_block)
|
|
180
|
-
else
|
|
181
|
-
chat.ask(extract_message(initial_input), &streaming_block)
|
|
182
|
-
end
|
|
194
|
+
pending = adapter.stream_async(chat, message, config: config, &streaming_block)
|
|
195
|
+
response = pending.await
|
|
183
196
|
|
|
184
197
|
usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
|
|
185
198
|
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
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Centralises tool execution routing based on {Tool::Base.execution_mode}.
|
|
6
|
+
#
|
|
7
|
+
# This is the single place in the framework that decides *how* a tool call is
|
|
8
|
+
# dispatched:
|
|
9
|
+
#
|
|
10
|
+
# - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
|
|
11
|
+
# scheduler. Under the +:fiber+ backend this avoids an
|
|
12
|
+
# extra OS thread; under the +:thread+ backend it is
|
|
13
|
+
# backed by +ThreadScheduler+ (one thread per task).
|
|
14
|
+
# - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
|
|
15
|
+
# provides a pool; falls back to +Runtime#spawn+ otherwise.
|
|
16
|
+
# - +:cpu_bound+ — emits a deprecation-style warning then falls back to
|
|
17
|
+
# +:blocking_io+ routing (no process pool available yet).
|
|
18
|
+
# - +:external_process+ — falls back to +:blocking_io+ routing (no process
|
|
19
|
+
# manager available yet).
|
|
20
|
+
#
|
|
21
|
+
# All paths return an object that responds to +#await+ (+Phronomy::Task+ or
|
|
22
|
+
# +BlockingAdapterPool::PendingOperation+), so callers can collect results
|
|
23
|
+
# uniformly.
|
|
24
|
+
#
|
|
25
|
+
# @note Non-goals
|
|
26
|
+
# ToolExecutor deliberately does NOT provide:
|
|
27
|
+
# - A CPU-bound process pool. CPU-intensive tool work must be handled at the
|
|
28
|
+
# application layer (e.g., fork, Sidekiq, separate OS processes). The
|
|
29
|
+
# framework will not add a +ProcessPoolExecutor+ equivalent.
|
|
30
|
+
# - An external process manager. Spawning or supervising subprocesses is
|
|
31
|
+
# out of scope for this module.
|
|
32
|
+
# - Additional core execution routes beyond scheduler-backed cooperative
|
|
33
|
+
# execution and BlockingAdapterPool-backed blocking I/O isolation.
|
|
34
|
+
# The +:cpu_bound+ and +:external_process+ modes are accepted for
|
|
35
|
+
# compatibility but both fall back to +:blocking_io+ routing with a
|
|
36
|
+
# one-time warning. If a genuinely new core execution route is needed,
|
|
37
|
+
# a new ADR is required.
|
|
38
|
+
# These non-goals follow from the cooperative-first, non-preemptive
|
|
39
|
+
# concurrency model (ADR-010): framework components must not assume the
|
|
40
|
+
# caller's concurrency model, and CPU/process management belongs to the
|
|
41
|
+
# application layer.
|
|
42
|
+
#
|
|
43
|
+
# @api private
|
|
44
|
+
module ToolExecutor
|
|
45
|
+
# Tracks tool classes that have already emitted an execution_mode warning so
|
|
46
|
+
# that the same warning is only logged once per process lifetime.
|
|
47
|
+
WARNED_MODES = Set.new
|
|
48
|
+
WARNED_MODES_MUTEX = Mutex.new
|
|
49
|
+
private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
|
|
50
|
+
|
|
51
|
+
# Dispatches a single tool call asynchronously according to its
|
|
52
|
+
# +execution_mode+ and returns an awaitable.
|
|
53
|
+
#
|
|
54
|
+
# @param tool [Phronomy::Tool::Base] the tool instance to invoke
|
|
55
|
+
# @param args [Hash] argument hash to pass to {Tool::Base#call}
|
|
56
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
57
|
+
# @param runtime [Phronomy::Runtime] runtime to use for spawning
|
|
58
|
+
# (defaults to {Runtime.instance}; injectable for tests)
|
|
59
|
+
# @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
|
|
60
|
+
# @api private
|
|
61
|
+
def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
|
|
62
|
+
ct = cancellation_token
|
|
63
|
+
mode = tool.class.execution_mode
|
|
64
|
+
|
|
65
|
+
# Warn and normalise unsupported modes to :blocking_io.
|
|
66
|
+
# Each (tool class, mode) pair emits the warning at most once per process
|
|
67
|
+
# lifetime to avoid log flooding in high-throughput scenarios.
|
|
68
|
+
if mode == :cpu_bound || mode == :external_process
|
|
69
|
+
warn_key = [tool.class.name, mode]
|
|
70
|
+
newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
|
|
71
|
+
if newly_warned
|
|
72
|
+
msg = if mode == :cpu_bound
|
|
73
|
+
"[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
|
|
74
|
+
"which has no dedicated executor. " \
|
|
75
|
+
"Falling back to blocking_io (BlockingAdapterPool). " \
|
|
76
|
+
"Use :blocking_io explicitly to suppress this warning."
|
|
77
|
+
else
|
|
78
|
+
"[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
|
|
79
|
+
"which has no dedicated process manager. " \
|
|
80
|
+
"Falling back to blocking_io (BlockingAdapterPool)."
|
|
81
|
+
end
|
|
82
|
+
if Phronomy.configuration.logger
|
|
83
|
+
Phronomy.configuration.logger.warn(msg)
|
|
84
|
+
else
|
|
85
|
+
warn msg
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
mode = :blocking_io
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
pool = begin
|
|
92
|
+
runtime&.blocking_io
|
|
93
|
+
rescue
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if mode == :cooperative || pool.nil?
|
|
98
|
+
runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
|
|
99
|
+
tool.call(args, cancellation_token: ct)
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
# Submit directly to pool — no wrapping Task thread required.
|
|
103
|
+
pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# A thread-safe FIFO queue for passing values between concurrent tasks.
|
|
6
|
+
#
|
|
7
|
+
# Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
|
|
8
|
+
# standard-library type directly. A future implementation may replace the
|
|
9
|
+
# backing primitive without changing call sites.
|
|
10
|
+
#
|
|
11
|
+
# @example Producer / consumer
|
|
12
|
+
# queue = Phronomy::Concurrency::AsyncQueue.new
|
|
13
|
+
# Runtime.instance.spawn { queue.push(expensive_io()) }
|
|
14
|
+
# value = queue.pop # blocks until the producer pushes
|
|
15
|
+
# @api private
|
|
16
|
+
class AsyncQueue
|
|
17
|
+
# @param max_size [Integer, nil] optional upper bound on queue depth.
|
|
18
|
+
# When set, {#push} blocks the caller until a slot is available.
|
|
19
|
+
# @api private
|
|
20
|
+
def initialize(max_size: nil)
|
|
21
|
+
@queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
|
|
22
|
+
@max_size = max_size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Enqueues +item+.
|
|
26
|
+
# In a cooperative scheduler context with a bounded queue (max_size:), suspends
|
|
27
|
+
# the current Fiber via a scheduler signal when the queue is full rather than
|
|
28
|
+
# blocking the OS thread. Without a scheduler, falls back to the standard
|
|
29
|
+
# SizedQueue blocking behaviour.
|
|
30
|
+
# @param item [Object] value to enqueue
|
|
31
|
+
# @return [self]
|
|
32
|
+
# @api private
|
|
33
|
+
def push(item)
|
|
34
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
35
|
+
if scheduler && @max_size
|
|
36
|
+
_push_cooperative(scheduler, item)
|
|
37
|
+
else
|
|
38
|
+
@queue.push(item)
|
|
39
|
+
scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
|
|
40
|
+
end
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Dequeues and returns the next item.
|
|
45
|
+
# In a cooperative scheduler context, suspends the current Fiber (yielding
|
|
46
|
+
# control back to the scheduler) rather than blocking the OS thread.
|
|
47
|
+
#
|
|
48
|
+
# When +timeout+ is given the semantics depend on the active backend:
|
|
49
|
+
#
|
|
50
|
+
# * **Thread backend** (`:thread`) — uses real wall-clock time via
|
|
51
|
+
# +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
|
|
52
|
+
# Returns +nil+ if no item arrives within the specified number of real seconds.
|
|
53
|
+
# * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
|
|
54
|
+
# *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
|
|
55
|
+
# the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
|
|
56
|
+
# In tests this means the timeout is fully deterministic and does not depend on
|
|
57
|
+
# actual elapsed wall time. However, in production `:fiber` mode the timeout
|
|
58
|
+
# may never expire unless the scheduler explicitly advances virtual time.
|
|
59
|
+
#
|
|
60
|
+
# @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
|
|
61
|
+
# in production workloads is not guaranteed and may differ from wall-clock
|
|
62
|
+
# expectations.
|
|
63
|
+
# @note **Cooperative timeout limitation**: on the cooperative path, the
|
|
64
|
+
# deadline is re-checked *after* a wake-up signal arrives. If virtual time
|
|
65
|
+
# has already passed the deadline when the consumer is woken by a producer
|
|
66
|
+
# push, the consumer returns +nil+ rather than the pushed item. Without any
|
|
67
|
+
# wake-up signal the waiting Fiber remains suspended even after
|
|
68
|
+
# +scheduler.advance+ — the timeout does not self-fire.
|
|
69
|
+
# @param timeout [Numeric, nil] seconds to wait before returning +nil+.
|
|
70
|
+
# Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
|
|
71
|
+
# @return [Object, nil] the next item, or +nil+ when timeout expires
|
|
72
|
+
# @api private
|
|
73
|
+
def pop(timeout: nil)
|
|
74
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
75
|
+
if scheduler
|
|
76
|
+
_pop_cooperative(scheduler, timeout: timeout)
|
|
77
|
+
elsif timeout
|
|
78
|
+
@queue.pop(timeout: timeout)
|
|
79
|
+
else
|
|
80
|
+
@queue.pop
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns the current number of items in the queue.
|
|
85
|
+
# @return [Integer]
|
|
86
|
+
# @api private
|
|
87
|
+
def size
|
|
88
|
+
@queue.size
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns +true+ when the queue contains no items.
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
# @api private
|
|
94
|
+
def empty?
|
|
95
|
+
@queue.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
|
|
99
|
+
# @return [self]
|
|
100
|
+
# @api private
|
|
101
|
+
def close
|
|
102
|
+
@queue.close
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Cooperative pop for DeterministicScheduler context.
|
|
109
|
+
# Suspends the current Fiber via the scheduler's signal mechanism rather than
|
|
110
|
+
# blocking the OS thread. Because cooperative mode is single-threaded, the
|
|
111
|
+
# empty?/pop pair is race-free (no other Fiber can run between the two calls).
|
|
112
|
+
# After dequeuing, notifies any push-waiter so that a backpressure-suspended
|
|
113
|
+
# producer can be unblocked.
|
|
114
|
+
# @api private
|
|
115
|
+
# @param scheduler [Runtime::Scheduler]
|
|
116
|
+
# @param timeout [Numeric, nil]
|
|
117
|
+
# @return [Object, nil]
|
|
118
|
+
def _pop_cooperative(scheduler, timeout:)
|
|
119
|
+
@coop_signal ||= scheduler.new_signal
|
|
120
|
+
deadline = timeout ? (scheduler.virtual_time + timeout) : nil
|
|
121
|
+
|
|
122
|
+
loop do
|
|
123
|
+
unless @queue.empty?
|
|
124
|
+
item = @queue.pop(timeout: 0)
|
|
125
|
+
# Notify a push-waiter (bounded queue) that a slot opened up.
|
|
126
|
+
scheduler.raise_signal(@push_signal) if @push_signal
|
|
127
|
+
return item
|
|
128
|
+
end
|
|
129
|
+
return nil if deadline && scheduler.virtual_time >= deadline
|
|
130
|
+
scheduler.wait_for_signal(@coop_signal)
|
|
131
|
+
return nil if deadline && scheduler.virtual_time >= deadline
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Cooperative push for DeterministicScheduler context with a bounded queue.
|
|
136
|
+
# Suspends the current Fiber via a scheduler signal when the queue is full,
|
|
137
|
+
# rather than blocking the OS thread.
|
|
138
|
+
# @api private
|
|
139
|
+
# @param scheduler [Runtime::Scheduler]
|
|
140
|
+
# @param item [Object]
|
|
141
|
+
# @return [void]
|
|
142
|
+
def _push_cooperative(scheduler, item)
|
|
143
|
+
@push_signal ||= scheduler.new_signal
|
|
144
|
+
|
|
145
|
+
loop do
|
|
146
|
+
unless @queue.size >= @max_size
|
|
147
|
+
@queue.push(item)
|
|
148
|
+
# Notify any pop-waiter that an item is now available.
|
|
149
|
+
scheduler.raise_signal(@coop_signal) if @coop_signal
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
scheduler.wait_for_signal(@push_signal)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|