phronomy 0.16.0 → 0.18.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 -9
- data/CHANGELOG.md +151 -1134
- data/CONTRIBUTING.md +28 -16
- data/README.md +69 -1249
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +26 -6
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +170 -333
- data/lib/phronomy/agent/context/capability/base.rb +177 -298
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +6 -7
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +48 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +180 -336
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +4 -171
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +49 -377
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
- data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -121
- data/scripts/api_snapshot.rb +4 -15
- metadata +24 -38
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -265
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
data/lib/phronomy/engine/task.rb
CHANGED
|
@@ -1,335 +1,194 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "task/backend"
|
|
4
|
-
require_relative "task/thread_backend"
|
|
5
|
-
require_relative "task/immediate_backend"
|
|
6
|
-
require_relative "task/fiber_backend"
|
|
7
|
-
require_relative "task/mapped_backend"
|
|
8
|
-
require_relative "task/deferred_backend"
|
|
9
|
-
|
|
10
3
|
module Phronomy
|
|
11
|
-
# A
|
|
12
|
-
#
|
|
13
|
-
# Decouples task semantics from the underlying execution primitive via a
|
|
14
|
-
# pluggable {Backend}. The default backend is {ThreadBackend}; a cooperative
|
|
15
|
-
# or test-double backend can be substituted via {.default_backend_class=} or
|
|
16
|
-
# by passing +backend_class:+ to {.spawn}.
|
|
17
|
-
#
|
|
18
|
-
# +Task.spawn+ is an **internal API** used by schedulers and the framework
|
|
19
|
-
# itself. Application code and framework components should use
|
|
20
|
-
# +Runtime.instance.spawn+ instead, which routes through the configured
|
|
21
|
-
# scheduler and respects the concurrency model.
|
|
4
|
+
# A thread-free asynchronous completion handle.
|
|
22
5
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# @example Cancel a running task
|
|
28
|
-
# task = Phronomy::Task.spawn { loop { Phronomy::Task.checkpoint! } }
|
|
29
|
-
# task.cancel!
|
|
30
|
-
#
|
|
31
|
-
# @example Task tree — cancel parent cancels children
|
|
32
|
-
# parent = Phronomy::Task.spawn { sleep 10 }
|
|
33
|
-
# child = Phronomy::Task.spawn(parent: parent) { sleep 10 }
|
|
34
|
-
# parent.cancel! # child is also cancelled
|
|
6
|
+
# Task no longer executes work. Execution belongs to EventLoop/FSMSession or
|
|
7
|
+
# OffloadPool. Task only represents completion, failure, cancellation,
|
|
8
|
+
# callbacks and a blocking wait for external callers.
|
|
35
9
|
class Task
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
# Returns the process-wide default backend class.
|
|
40
|
-
# Defaults to {ThreadBackend}.
|
|
41
|
-
# Override in tests or to enable a cooperative scheduler backend.
|
|
42
|
-
# @return [Class<Backend>]
|
|
43
|
-
# @api private
|
|
44
|
-
def self.default_backend_class
|
|
45
|
-
@default_backend_class || ThreadBackend
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Sets the process-wide default backend class.
|
|
49
|
-
# @param klass [Class<Backend>]
|
|
50
|
-
# @api private
|
|
51
|
-
def self.default_backend_class=(klass)
|
|
52
|
-
@default_backend_class = klass
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Returns the {Task} currently executing on this thread, or +nil+.
|
|
56
|
-
# Returns +nil+ when called from outside a task-managed execution context.
|
|
57
|
-
# @return [Task, nil]
|
|
58
|
-
# @api private
|
|
59
|
-
def self.current
|
|
60
|
-
Thread.current[:phronomy_current_task]
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Returns the monotonic clock value (ms) when the current task last recorded
|
|
64
|
-
# a yield (or when the task started), or +nil+ when not inside a task context.
|
|
65
|
-
# Used by {Runtime#yield} for CPU-bound detection without placing
|
|
66
|
-
# +Thread.current+ in files outside the allowlist.
|
|
67
|
-
# @return [Integer, nil]
|
|
68
|
-
# @api private
|
|
69
|
-
def self.current_cpu_slice_start_ms
|
|
70
|
-
Thread.current[:phronomy_task_cpu_slice_start_ms]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Resets the CPU slice start clock for the current task to +now+.
|
|
74
|
-
# Call this immediately after the cooperative yield has been performed so
|
|
75
|
-
# that the next yield correctly measures only the time since the last yield.
|
|
76
|
-
# @api private
|
|
77
|
-
def self.record_yield!
|
|
78
|
-
Thread.current[:phronomy_task_cpu_slice_start_ms] =
|
|
79
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Returns and increments a per-thread yield-if-needed counter.
|
|
83
|
-
# Used by {Runtime#yield_if_needed} so that the counter is thread-local
|
|
84
|
-
# without putting +Thread.current+ in runtime.rb (which is outside the
|
|
85
|
-
# Thread.current allowlist).
|
|
86
|
-
# @return [Integer] the new counter value
|
|
87
|
-
# @api private
|
|
88
|
-
def self.increment_yield_counter!
|
|
89
|
-
count = (Thread.current[:phronomy_yield_if_needed_counter] || 0) + 1
|
|
90
|
-
Thread.current[:phronomy_yield_if_needed_counter] = count
|
|
91
|
-
count
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Cooperative cancellation checkpoint.
|
|
95
|
-
#
|
|
96
|
-
# Raises {CancellationError} if the current task's status is +:cancelled+.
|
|
97
|
-
# On {ThreadBackend}, cancellation is delivered via +Thread#raise+ so this
|
|
98
|
-
# is a no-op in practice; on future cooperative backends this will be the
|
|
99
|
-
# primary cancellation mechanism.
|
|
100
|
-
#
|
|
101
|
-
# Safe to call from outside a task context (no-op when no current task).
|
|
102
|
-
# @return [void]
|
|
103
|
-
# @raise [CancellationError] if the current task has been cancelled
|
|
104
|
-
# @api private
|
|
105
|
-
def self.checkpoint!
|
|
106
|
-
ct = current
|
|
107
|
-
return unless ct
|
|
108
|
-
|
|
109
|
-
raise CancellationError, "Task cancelled" if ct.status == :cancelled
|
|
110
|
-
end
|
|
10
|
+
STATES = %i[pending completed failed cancelled].freeze
|
|
11
|
+
TERMINAL_STATES = %i[completed failed cancelled].freeze
|
|
12
|
+
private_constant :TERMINAL_STATES
|
|
111
13
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
# @param name [String, nil] optional human-readable label
|
|
115
|
-
# @param parent [Task, nil] parent task; cancelling the parent
|
|
116
|
-
# also cancels this task (default: currently running task)
|
|
117
|
-
# @param backend_class [Class<Backend>] backend to use
|
|
118
|
-
# @yieldreturn [Object] the task result
|
|
119
|
-
# @return [Task]
|
|
120
|
-
# @api private
|
|
121
|
-
def self.spawn(name: nil, parent: current, backend_class: default_backend_class, &block)
|
|
122
|
-
new(name: name, parent: parent, backend_class: backend_class, &block)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# Creates a task whose lifecycle is completed externally.
|
|
126
|
-
# @param name [String, nil]
|
|
127
|
-
# @param parent [Task, nil]
|
|
128
|
-
# @return [Task]
|
|
129
|
-
# @api private
|
|
130
|
-
def self.deferred(name: nil, parent: current)
|
|
131
|
-
new(name: name, parent: parent, backend_class: DeferredBackend) {}
|
|
14
|
+
def self.deferred(name: nil, parent: nil)
|
|
15
|
+
new(name: name, parent: parent)
|
|
132
16
|
end
|
|
133
17
|
|
|
134
|
-
|
|
135
|
-
attr_reader :name
|
|
18
|
+
attr_reader :name, :parent
|
|
136
19
|
|
|
137
|
-
|
|
138
|
-
attr_reader :parent
|
|
139
|
-
|
|
140
|
-
# @return [Backend] the execution backend for this task
|
|
141
|
-
# @api private
|
|
142
|
-
attr_reader :backend
|
|
143
|
-
|
|
144
|
-
# @param name [String, nil]
|
|
145
|
-
# @param parent [Task, nil]
|
|
146
|
-
# @param backend_class [Class<Backend>]
|
|
147
|
-
# @api private use {.spawn} instead
|
|
148
|
-
def initialize(name: nil, parent: nil, backend_class: self.class.default_backend_class, &block)
|
|
20
|
+
def initialize(name: nil, parent: nil)
|
|
149
21
|
@name = name
|
|
150
22
|
@parent = parent
|
|
151
23
|
@status = :pending
|
|
24
|
+
@value = nil
|
|
25
|
+
@error = nil
|
|
152
26
|
@mutex = Mutex.new
|
|
27
|
+
@cond = ConditionVariable.new
|
|
153
28
|
@children = []
|
|
154
29
|
@on_complete_callbacks = []
|
|
155
|
-
@completed_value = nil
|
|
156
|
-
@completed_error = nil
|
|
157
30
|
parent&.register_child(self)
|
|
158
|
-
@backend = backend_class.new(task: self, &block)
|
|
159
31
|
end
|
|
160
32
|
|
|
161
|
-
# Returns the current lifecycle state.
|
|
162
|
-
# @return [Symbol] one of {STATES}
|
|
163
|
-
# @api private
|
|
164
33
|
def status
|
|
165
34
|
@mutex.synchronize { @status }
|
|
166
35
|
end
|
|
167
36
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
#
|
|
171
|
-
# @return [Object] the result produced by the block
|
|
172
|
-
# @raise [Exception] if the block raised an error
|
|
173
|
-
# @api private
|
|
174
|
-
def wait_result
|
|
175
|
-
@backend.wait_result
|
|
37
|
+
def done?
|
|
38
|
+
@mutex.synchronize { TERMINAL_STATES.include?(@status) }
|
|
176
39
|
end
|
|
177
40
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
41
|
+
def alive?
|
|
42
|
+
!done?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Blocks the calling thread until settlement. EventLoop is never allowed to
|
|
46
|
+
# wait for a Task; it must continue through explicit events instead.
|
|
47
|
+
def wait_result(timeout: nil)
|
|
48
|
+
if Phronomy::Runtime.in_event_loop_context? && !done?
|
|
49
|
+
raise Phronomy::EventLoopReentrancyError,
|
|
50
|
+
"Task#wait_result cannot block the EventLoop thread; continue via an event"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
deadline = timeout && monotonic_now + timeout.to_f
|
|
54
|
+
value, error = @mutex.synchronize do
|
|
55
|
+
until TERMINAL_STATES.include?(@status)
|
|
56
|
+
if deadline
|
|
57
|
+
remaining = deadline - monotonic_now
|
|
58
|
+
if remaining <= 0
|
|
59
|
+
raise Phronomy::TimeoutError,
|
|
60
|
+
"timed out waiting for Task #{@name || "(unnamed)"}"
|
|
61
|
+
end
|
|
62
|
+
@cond.wait(@mutex, remaining)
|
|
63
|
+
else
|
|
64
|
+
@cond.wait(@mutex)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
[@value, @error]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
raise error if error
|
|
71
|
+
value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Compatibility wait that does not re-raise the task error.
|
|
75
|
+
# Returns self when settled, nil on timeout.
|
|
76
|
+
def join(limit = nil)
|
|
77
|
+
if Phronomy::Runtime.in_event_loop_context? && !done?
|
|
78
|
+
raise Phronomy::EventLoopReentrancyError,
|
|
79
|
+
"Task#join cannot block the EventLoop thread; continue via an event"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
deadline = limit && monotonic_now + limit.to_f
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
until TERMINAL_STATES.include?(@status)
|
|
85
|
+
if deadline
|
|
86
|
+
remaining = deadline - monotonic_now
|
|
87
|
+
return nil if remaining <= 0
|
|
88
|
+
@cond.wait(@mutex, remaining)
|
|
89
|
+
else
|
|
90
|
+
@cond.wait(@mutex)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Registers an independent completion notification.
|
|
188
98
|
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
# @api private
|
|
99
|
+
# A callback failure is logged and does not suppress delivery to other
|
|
100
|
+
# completion callbacks or change the Task's already-settled result.
|
|
192
101
|
def on_complete(&callback)
|
|
193
|
-
|
|
102
|
+
raise ArgumentError, "on_complete requires a block" unless callback
|
|
103
|
+
|
|
194
104
|
fire_args = nil
|
|
195
105
|
@mutex.synchronize do
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if %i[completed failed cancelled].include?(@status)
|
|
199
|
-
fire_now = true
|
|
200
|
-
fire_args = [@completed_value, @completed_error]
|
|
106
|
+
if TERMINAL_STATES.include?(@status)
|
|
107
|
+
fire_args = [@value, @error]
|
|
201
108
|
else
|
|
202
109
|
@on_complete_callbacks << callback
|
|
203
110
|
end
|
|
204
111
|
end
|
|
205
|
-
callback
|
|
112
|
+
deliver_completion_callback(callback, *fire_args) if fire_args
|
|
113
|
+
self
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def complete(value = nil)
|
|
117
|
+
settle!(:completed, value: value)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fail(error)
|
|
121
|
+
raise ArgumentError, "error is required" unless error
|
|
122
|
+
settle!(:failed, error: error)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cancel!(error = Phronomy::CancellationError.new("Task cancelled"))
|
|
126
|
+
changed = settle!(:cancelled, error: error)
|
|
127
|
+
if changed
|
|
128
|
+
children = @mutex.synchronize { @children.dup }
|
|
129
|
+
children.each(&:cancel!)
|
|
130
|
+
end
|
|
206
131
|
self
|
|
207
132
|
end
|
|
208
133
|
|
|
209
|
-
# Returns a new {Task} whose completed value is the result of applying
|
|
210
|
-
# +block+ to this task's completed value.
|
|
211
|
-
#
|
|
212
|
-
# If this task fails or is cancelled, the mapped task also fails/is
|
|
213
|
-
# cancelled with the same error. The block is never called in error cases.
|
|
214
|
-
#
|
|
215
|
-
# The transformation runs from the source Task's completion callback.
|
|
216
|
-
# It is a generic value-composition API; Workflow entry actions do not await
|
|
217
|
-
# either the source Task or the mapped Task.
|
|
218
|
-
#
|
|
219
|
-
# @example Transforming an Agent result outside a Workflow entry action
|
|
220
|
-
# output_task = agent.invoke_async("hello").map { |result| result[:output] }
|
|
221
|
-
#
|
|
222
|
-
# @yield [value] the completed value of this task
|
|
223
|
-
# @yieldreturn [Object] the value for the mapped task
|
|
224
|
-
# @return [Task] a new task that completes when this task does
|
|
225
|
-
# @api public
|
|
226
134
|
def map(&block)
|
|
227
|
-
|
|
228
|
-
# it never spawns a thread of its own.
|
|
229
|
-
mapped = self.class.spawn(
|
|
230
|
-
name: "#{@name}-mapped",
|
|
231
|
-
parent: @parent,
|
|
232
|
-
backend_class: MappedBackend
|
|
233
|
-
) {}
|
|
135
|
+
raise ArgumentError, "map requires a block" unless block
|
|
234
136
|
|
|
137
|
+
mapped = self.class.deferred(name: "#{@name}-mapped", parent: @parent)
|
|
235
138
|
on_complete do |value, error|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
begin
|
|
240
|
-
mapped_value = block.call(value)
|
|
241
|
-
rescue => e
|
|
242
|
-
mapped_error = e
|
|
243
|
-
end
|
|
139
|
+
if error
|
|
140
|
+
mapped.fail(error)
|
|
141
|
+
next
|
|
244
142
|
end
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
mapped.transition!(:failed, error: mapped_error)
|
|
251
|
-
else
|
|
252
|
-
mapped.transition!(:completed, value: mapped_value)
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
mapped.complete(block.call(value))
|
|
146
|
+
rescue => mapped_error
|
|
147
|
+
mapped.fail(mapped_error)
|
|
253
148
|
end
|
|
254
149
|
end
|
|
255
150
|
mapped
|
|
256
151
|
end
|
|
257
152
|
|
|
258
|
-
|
|
259
|
-
# @return [Boolean]
|
|
260
|
-
# @api private
|
|
261
|
-
def done?
|
|
262
|
-
%i[completed failed cancelled].include?(status)
|
|
263
|
-
end
|
|
153
|
+
protected
|
|
264
154
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
# started executing yet are correctly marked as cancelled after join.
|
|
268
|
-
# Passes a CancellationError to on_complete callbacks so callers do not
|
|
269
|
-
# need to call await to discover the error.
|
|
270
|
-
# @return [self]
|
|
271
|
-
# @api private
|
|
272
|
-
def cancel!
|
|
273
|
-
transition!(:cancelled, error: CancellationError.new("Task cancelled"))
|
|
274
|
-
# @backend may be nil if cancel! is called while ImmediateBackend is still
|
|
275
|
-
# initializing (the block runs synchronously inside .new, so register_child
|
|
276
|
-
# fires before @backend is assigned). Safe-navigate to avoid NoMethodError.
|
|
277
|
-
@backend&.cancel!
|
|
278
|
-
children = @mutex.synchronize { @children.dup }
|
|
279
|
-
children.each(&:cancel!)
|
|
280
|
-
self
|
|
155
|
+
def register_child(child)
|
|
156
|
+
@mutex.synchronize { @children << child }
|
|
281
157
|
end
|
|
282
158
|
|
|
283
|
-
|
|
284
|
-
# Returns +nil+ when the timeout expires before completion.
|
|
285
|
-
#
|
|
286
|
-
# @param limit [Numeric, nil] seconds to wait; nil waits indefinitely
|
|
287
|
-
# @return [Object, nil]
|
|
288
|
-
# @api private
|
|
289
|
-
def join(limit = nil)
|
|
290
|
-
@backend.join(limit)
|
|
291
|
-
end
|
|
159
|
+
private
|
|
292
160
|
|
|
293
|
-
|
|
294
|
-
# @return [Boolean]
|
|
295
|
-
# @api private
|
|
296
|
-
def alive?
|
|
297
|
-
@backend.alive?
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
# Updates the task lifecycle state.
|
|
301
|
-
# Called by backends during execution transitions.
|
|
302
|
-
# Terminal states (completed/failed/cancelled) are never overwritten.
|
|
303
|
-
# When a terminal state is reached, fires on_complete callbacks (outside
|
|
304
|
-
# the mutex) passing the result value and error directly.
|
|
305
|
-
#
|
|
306
|
-
# @param new_status [Symbol]
|
|
307
|
-
# @param value [Object, nil] task return value (terminal states only)
|
|
308
|
-
# @param error [Exception, nil] exception raised by the block, if any
|
|
309
|
-
# @api private
|
|
310
|
-
def transition!(new_status, value: nil, error: nil)
|
|
161
|
+
def settle!(new_status, value: nil, error: nil)
|
|
311
162
|
callbacks = nil
|
|
312
|
-
@mutex.synchronize do
|
|
313
|
-
|
|
314
|
-
return if %i[completed failed cancelled].include?(@status)
|
|
163
|
+
changed = @mutex.synchronize do
|
|
164
|
+
next false if TERMINAL_STATES.include?(@status)
|
|
315
165
|
|
|
316
166
|
@status = new_status
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
167
|
+
@value = value
|
|
168
|
+
@error = error
|
|
169
|
+
callbacks = @on_complete_callbacks.dup
|
|
170
|
+
@on_complete_callbacks.clear
|
|
171
|
+
@cond.broadcast
|
|
172
|
+
true
|
|
173
|
+
end
|
|
174
|
+
if changed
|
|
175
|
+
callbacks.each do |callback|
|
|
176
|
+
deliver_completion_callback(callback, value, error)
|
|
322
177
|
end
|
|
323
178
|
end
|
|
324
|
-
|
|
179
|
+
changed
|
|
325
180
|
end
|
|
326
181
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
182
|
+
def deliver_completion_callback(callback, value, error)
|
|
183
|
+
callback.call(value, error)
|
|
184
|
+
rescue => callback_error
|
|
185
|
+
Phronomy.configuration.logger&.error do
|
|
186
|
+
"[Task] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def monotonic_now
|
|
191
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
333
192
|
end
|
|
334
193
|
end
|
|
335
194
|
end
|
|
@@ -6,11 +6,11 @@ module Phronomy
|
|
|
6
6
|
#
|
|
7
7
|
# Subclasses must implement {#complete} and {#stream}. The adapter or the
|
|
8
8
|
# underlying provider client owns transport timeout, retry, backoff, and
|
|
9
|
-
# rate-limit behavior. Phronomy
|
|
10
|
-
# isolates
|
|
9
|
+
# rate-limit behavior. Phronomy supplies cooperative cancellation and
|
|
10
|
+
# isolates synchronous provider calls in {OffloadPool}.
|
|
11
11
|
#
|
|
12
12
|
# The agent pipeline calls {#complete_async} / {#stream_async} which wrap
|
|
13
|
-
# those methods in
|
|
13
|
+
# those methods in an {OffloadPool} submission.
|
|
14
14
|
class Base
|
|
15
15
|
# Performs a blocking (non-streaming) LLM completion.
|
|
16
16
|
# Implementors must call +chat.ask(message)+ (or equivalent) and
|
|
@@ -41,8 +41,8 @@ module Phronomy
|
|
|
41
41
|
raise NotImplementedError, "#{self.class}#stream is not implemented"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
# Submits a non-streaming LLM call to {
|
|
45
|
-
#
|
|
44
|
+
# Submits a non-streaming LLM call to {OffloadPool} and returns
|
|
45
|
+
# an {OffloadPool::PendingOperation}.
|
|
46
46
|
#
|
|
47
47
|
# Transport timeout and retry remain the responsibility of the adapter or
|
|
48
48
|
# provider client; Phronomy does not attach an additional operation timeout.
|
|
@@ -50,20 +50,20 @@ module Phronomy
|
|
|
50
50
|
# @param chat [Object] configured chat session
|
|
51
51
|
# @param message [String] user message
|
|
52
52
|
# @param config [Hash] invocation config
|
|
53
|
-
# @param pool [
|
|
54
|
-
# @return [
|
|
53
|
+
# @param pool [OffloadPool] pool to submit to
|
|
54
|
+
# @return [OffloadPool::PendingOperation]
|
|
55
55
|
# @api private
|
|
56
56
|
def complete_async(chat, message, config: {}, pool: default_pool)
|
|
57
57
|
token = config[:cancellation_token]
|
|
58
|
-
pool.submit(cancellation_token: token) do
|
|
58
|
+
pool.submit(cancellation_token: token, on_full: :raise) do
|
|
59
59
|
complete(chat, message, config: config)
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
# Submits a streaming LLM call to {
|
|
64
|
-
#
|
|
63
|
+
# Submits a streaming LLM call to {OffloadPool} and returns
|
|
64
|
+
# an {OffloadPool::PendingOperation}.
|
|
65
65
|
#
|
|
66
|
-
# The block is invoked on
|
|
66
|
+
# The block is invoked on an OffloadPool worker thread. Agent code must
|
|
67
67
|
# pass only a lightweight internal sink that posts a value to EventLoop;
|
|
68
68
|
# Application callbacks must never be passed directly to this method.
|
|
69
69
|
#
|
|
@@ -71,13 +71,13 @@ module Phronomy
|
|
|
71
71
|
# provider client; Phronomy does not attach an additional operation timeout.
|
|
72
72
|
#
|
|
73
73
|
# @yield [chunk] streaming chunk on the worker thread
|
|
74
|
-
# @return [
|
|
74
|
+
# @return [OffloadPool::PendingOperation]
|
|
75
75
|
# @api private
|
|
76
76
|
def stream_async(chat, message, config: {}, pool: default_pool, &block)
|
|
77
77
|
raise ArgumentError, "stream_async requires a block" unless block
|
|
78
78
|
|
|
79
79
|
token = config[:cancellation_token]
|
|
80
|
-
pool.submit(cancellation_token: token) do
|
|
80
|
+
pool.submit(cancellation_token: token, on_full: :raise) do
|
|
81
81
|
stream(chat, message, config: config) do |chunk|
|
|
82
82
|
token&.raise_if_cancelled!("invocation cancelled during streaming")
|
|
83
83
|
block.call(chunk)
|
|
@@ -88,7 +88,7 @@ module Phronomy
|
|
|
88
88
|
private
|
|
89
89
|
|
|
90
90
|
def default_pool
|
|
91
|
-
Phronomy::Runtime.instance.
|
|
91
|
+
Phronomy::Runtime.instance.offload
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
end
|
|
@@ -4,10 +4,9 @@ module Phronomy
|
|
|
4
4
|
module LLMAdapter
|
|
5
5
|
# LLM adapter that delegates to the RubyLLM blocking client.
|
|
6
6
|
#
|
|
7
|
-
# This is the default adapter used by Phronomy agents.
|
|
8
|
-
# +chat.ask+ (and its streaming variant) so that the
|
|
9
|
-
# call runs inside {
|
|
10
|
-
# thread or the caller's thread directly.
|
|
7
|
+
# This is the default adapter used by Phronomy agents. It wraps
|
|
8
|
+
# +chat.ask+ (and its streaming variant) so that the synchronous provider
|
|
9
|
+
# call runs inside {OffloadPool} rather than on the EventLoop thread.
|
|
11
10
|
#
|
|
12
11
|
# @example Explicitly configuring this adapter
|
|
13
12
|
# Phronomy.configure do |c|
|
data/lib/phronomy/llm_adapter.rb
CHANGED
|
@@ -7,8 +7,8 @@ module Phronomy
|
|
|
7
7
|
# dependency on the RubyLLM blocking client. All LLM calls in
|
|
8
8
|
# {Agent::Base} are routed through the adapter so that:
|
|
9
9
|
#
|
|
10
|
-
# -
|
|
11
|
-
#
|
|
10
|
+
# - Synchronous provider work can be submitted to {OffloadPool} for bounded
|
|
11
|
+
# off-EventLoop execution.
|
|
12
12
|
# - Alternative LLM clients can be swapped in without changing agent code.
|
|
13
13
|
#
|
|
14
14
|
# @example Configuring a custom adapter
|