phronomy 0.28.0 → 0.29.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/README.md +20 -5
- data/docs/application-recipes.md +229 -0
- data/docs/architecture/dx05-agent-scope.md +119 -0
- data/docs/architecture/dx05-workflow-task-state.md +134 -0
- data/docs/architecture/error-api-review.md +245 -0
- data/docs/failure-reason-codes.md +45 -0
- data/docs/features.md +4 -1
- data/docs/getting-started.md +44 -43
- data/docs/ordered-event-delivery.md +54 -0
- data/docs/workflow-completion.md +92 -0
- data/gemfiles/http_minimum.gemfile +5 -0
- data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
- data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
- data/lib/phronomy/agent/api/agent.rb +13 -0
- data/lib/phronomy/agent/async_event_api.rb +111 -87
- data/lib/phronomy/agent/base.rb +74 -43
- data/lib/phronomy/agent/before_llm_input.rb +7 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +11 -9
- data/lib/phronomy/agent/execution/execution_coordinator.rb +16 -13
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -1
- data/lib/phronomy/agent/execution/initial_preparation.rb +1 -4
- data/lib/phronomy/agent/execution/runtime_record_encoder.rb +1 -4
- data/lib/phronomy/agent/filterable.rb +21 -15
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
- data/lib/phronomy/agent/lifecycle/scope.rb +198 -0
- data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +19 -0
- data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +7 -7
- data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
- data/lib/phronomy/agent/recovery/recovery_coordinator.rb +5 -9
- data/lib/phronomy/agent/recovery/recovery_support.rb +2 -8
- data/lib/phronomy/agent/stream_callback_error.rb +2 -2
- data/lib/phronomy/common/error.rb +65 -1
- data/lib/phronomy/content_store/base.rb +7 -2
- data/lib/phronomy/context/assembly.rb +11 -18
- data/lib/phronomy/embeddings/authentication_error.rb +11 -0
- data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +5 -1
- data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
- data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
- data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
- data/lib/phronomy/execution/task_result.rb +7 -2
- data/lib/phronomy/filter/filter_block_error.rb +2 -2
- data/lib/phronomy/generation/low_confidence_error.rb +2 -2
- data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
- data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
- data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
- data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
- data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
- data/lib/phronomy/storage/condition_failed_error.rb +2 -2
- data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
- data/lib/phronomy/tool/base.rb +25 -23
- data/lib/phronomy/tracing/base.rb +35 -6
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +4 -29
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +30 -2
- data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
- data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
- data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
- data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
- data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
- data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
- data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
- data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
- data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/scripts/check_readme_ruby.rb +24 -29
- data/scripts/check_readme_runnable.rb +92 -81
- data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
- data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
- data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
- data/sig/phronomy/agent.rbs +18 -0
- data/sig/phronomy/embeddings/base.rbs +6 -0
- data/sig/phronomy/execution/execution.rbs +2 -2
- data/sig/phronomy/extensions.rbs +1 -0
- data/sig/phronomy/filter/filter_block_error.rbs +6 -0
- data/sig/phronomy/generation/low_confidence_error.rbs +6 -0
- data/sig/phronomy/integrations.rbs +14 -0
- data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
- data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
- data/sig/phronomy/storage/contracts.rbs +2 -2
- data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
- data/sig/phronomy/workflow.rbs +4 -0
- data/sig/phronomy.rbs +4 -0
- metadata +49 -2
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transport_error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Embeddings
|
|
7
|
+
# Provider refusal; this classification alone does not authorize retries.
|
|
8
|
+
# @api public
|
|
9
|
+
class AuthenticationError < TransportError; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -40,8 +40,12 @@ module Phronomy
|
|
|
40
40
|
opts[:provider] = @provider if @provider
|
|
41
41
|
opts[:assume_model_exists] = true if @assume_model_exists
|
|
42
42
|
::RubyLLM.embed(text, **opts).vectors
|
|
43
|
+
rescue ::RubyLLM::UnauthorizedError, ::RubyLLM::ForbiddenError => error
|
|
44
|
+
raise AuthenticationError.new(error.message, code: "embeddings.authentication_failed"), cause: error
|
|
45
|
+
rescue ::RubyLLM::RateLimitError => error
|
|
46
|
+
raise RateLimitError.new(error.message, code: "embeddings.rate_limited"), cause: error
|
|
43
47
|
rescue ::RubyLLM::Error => error
|
|
44
|
-
raise TransportError, error
|
|
48
|
+
raise TransportError.new(error.message), cause: error
|
|
45
49
|
end
|
|
46
50
|
end
|
|
47
51
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transport_error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Embeddings
|
|
7
|
+
# Provider refusal; this classification alone does not authorize retries.
|
|
8
|
+
# @api public
|
|
9
|
+
class RateLimitError < TransportError; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -6,9 +6,9 @@ module Phronomy
|
|
|
6
6
|
class ExecutionCancellationError < CancellationError
|
|
7
7
|
attr_reader :outcomes
|
|
8
8
|
|
|
9
|
-
def initialize(message = "Execution cancelled", outcomes:)
|
|
9
|
+
def initialize(message = "Execution cancelled", outcomes:, code: nil)
|
|
10
10
|
@outcomes = outcomes
|
|
11
|
-
super(message)
|
|
11
|
+
super(message, code: code)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -6,9 +6,9 @@ module Phronomy
|
|
|
6
6
|
class ExecutionTimeoutError < TimeoutError
|
|
7
7
|
attr_reader :outcomes
|
|
8
8
|
|
|
9
|
-
def initialize(message = "Execution timed out", outcomes:)
|
|
9
|
+
def initialize(message = "Execution timed out", outcomes:, code: nil)
|
|
10
10
|
@outcomes = outcomes
|
|
11
|
-
super(message)
|
|
11
|
+
super(message, code: code)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -308,8 +308,13 @@ module Phronomy
|
|
|
308
308
|
def deliver_completion_callback(callback, value, error)
|
|
309
309
|
callback.call(value, error)
|
|
310
310
|
rescue => callback_error
|
|
311
|
-
|
|
312
|
-
|
|
311
|
+
begin
|
|
312
|
+
Phronomy::RuntimeSettings.current.logger&.error do
|
|
313
|
+
"[TaskResult] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
|
|
314
|
+
end
|
|
315
|
+
rescue
|
|
316
|
+
# Reporting must not interrupt delivery to the remaining observers.
|
|
317
|
+
nil
|
|
313
318
|
end
|
|
314
319
|
end
|
|
315
320
|
|
|
@@ -6,9 +6,9 @@ module Phronomy
|
|
|
6
6
|
class LowConfidenceError < Error
|
|
7
7
|
attr_reader :result
|
|
8
8
|
|
|
9
|
-
def initialize(result)
|
|
9
|
+
def initialize(result, code: nil)
|
|
10
10
|
@result = result
|
|
11
|
-
super("Answer confidence #{result.confidence} is below the required threshold")
|
|
11
|
+
super("Answer confidence #{result.confidence} is below the required threshold", code: code)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../common/error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Integrations
|
|
7
|
+
# Optional transport helper. Payload meaning and destinations belong to the app.
|
|
8
|
+
# @api public
|
|
9
|
+
class OrderedEventDelivery
|
|
10
|
+
class ClosedError < Phronomy::Error; end
|
|
11
|
+
class OverflowError < Phronomy::Error; end
|
|
12
|
+
|
|
13
|
+
# Close and drain after the block, preserving its result or original failure.
|
|
14
|
+
# A flush timeout limits waiting, not an in-flight network request.
|
|
15
|
+
# @api public
|
|
16
|
+
def self.open(deliver:, capacity: 256, batch_size: 32, flush_timeout: 30, prepare_batch: nil)
|
|
17
|
+
raise ArgumentError, "block required" unless block_given?
|
|
18
|
+
reject_event_loop!
|
|
19
|
+
validate_timeout!(flush_timeout)
|
|
20
|
+
raise ArgumentError, "deliver must be callable" unless deliver.respond_to?(:call)
|
|
21
|
+
delivery = new(capacity: capacity, batch_size: batch_size, prepare_batch: prepare_batch, &deliver)
|
|
22
|
+
begin
|
|
23
|
+
yield delivery
|
|
24
|
+
ensure
|
|
25
|
+
primary = $!
|
|
26
|
+
begin
|
|
27
|
+
delivery.close_and_wait(timeout: flush_timeout)
|
|
28
|
+
rescue => secondary
|
|
29
|
+
if primary
|
|
30
|
+
report_secondary(secondary)
|
|
31
|
+
else
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.reject_event_loop!
|
|
39
|
+
if Phronomy::Runtime.in_event_loop_context?
|
|
40
|
+
raise Phronomy::EventLoopReentrancyError, "delivery flush cannot block EventLoop"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.validate_timeout!(timeout)
|
|
45
|
+
valid = timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
|
|
46
|
+
raise ArgumentError, "flush timeout must be a finite nonnegative number or nil" unless valid
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.report_secondary(error)
|
|
50
|
+
Phronomy.configuration.logger&.error("[OrderedEventDelivery] secondary delivery failure: #{error.class}: #{error.message}")
|
|
51
|
+
rescue
|
|
52
|
+
# Diagnostic reporting must not replace the primary application error.
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
private_class_method :reject_event_loop!, :validate_timeout!, :report_secondary
|
|
56
|
+
|
|
57
|
+
# Construction does not start a worker or create a pool.
|
|
58
|
+
# @api public
|
|
59
|
+
def initialize(capacity: 256, batch_size: 32, prepare_batch: nil, &deliver)
|
|
60
|
+
raise ArgumentError, "deliver block required" unless deliver
|
|
61
|
+
unless capacity.is_a?(Integer) && capacity.positive? && batch_size.is_a?(Integer) && batch_size.positive?
|
|
62
|
+
raise ArgumentError, "capacity and batch_size must be positive Integers"
|
|
63
|
+
end
|
|
64
|
+
raise ArgumentError, "prepare_batch must be callable" if prepare_batch && !prepare_batch.respond_to?(:call)
|
|
65
|
+
@capacity, @batch_size = capacity, batch_size
|
|
66
|
+
@prepare_batch, @deliver = prepare_batch, deliver
|
|
67
|
+
@mutex = Mutex.new
|
|
68
|
+
@condition = ConditionVariable.new
|
|
69
|
+
@queue = []
|
|
70
|
+
@accepting = true
|
|
71
|
+
@worker = nil
|
|
72
|
+
@failure = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Snapshot plain data; admission order is the queue insertion order.
|
|
76
|
+
# Capacity counts queued payloads, excluding the active batch.
|
|
77
|
+
# @api public
|
|
78
|
+
def publish(payload)
|
|
79
|
+
value = immutable_copy(payload)
|
|
80
|
+
ticket = @mutex.synchronize do
|
|
81
|
+
raise @failure if @failure
|
|
82
|
+
raise ClosedError.new("event delivery closed", code: "delivery.closed") unless @accepting
|
|
83
|
+
raise OverflowError.new("event delivery queue full", code: "delivery.capacity_exceeded") if @queue.length >= @capacity
|
|
84
|
+
@queue << value
|
|
85
|
+
next nil if @worker
|
|
86
|
+
@worker = {started: false}
|
|
87
|
+
end
|
|
88
|
+
start_drain(ticket) if ticket
|
|
89
|
+
error = @mutex.synchronize { @failure }
|
|
90
|
+
raise error if error
|
|
91
|
+
self
|
|
92
|
+
rescue => error
|
|
93
|
+
# Listener dispatch can log an exception and continue. Retain the failure
|
|
94
|
+
# for close while continuing to drain already accepted work if possible.
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
@failure ||= error
|
|
97
|
+
@accepting = false
|
|
98
|
+
end
|
|
99
|
+
raise
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# External callers only. Concurrent close calls observe the same drain.
|
|
103
|
+
# Timeout does not abort a network operation or settle the worker's result.
|
|
104
|
+
# @api public
|
|
105
|
+
def close_and_wait(timeout: nil)
|
|
106
|
+
self.class.send(:reject_event_loop!)
|
|
107
|
+
self.class.send(:validate_timeout!, timeout)
|
|
108
|
+
deadline = timeout && monotonic_now + timeout
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
@accepting = false
|
|
111
|
+
while @worker
|
|
112
|
+
remaining = deadline && deadline - monotonic_now
|
|
113
|
+
raise Phronomy::TimeoutError, "event delivery flush timed out" if remaining && remaining <= 0
|
|
114
|
+
@condition.wait(@mutex, remaining)
|
|
115
|
+
end
|
|
116
|
+
raise @failure if @failure
|
|
117
|
+
end
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def start_drain(ticket)
|
|
124
|
+
Phronomy::Blocking.call_async do
|
|
125
|
+
started = @mutex.synchronize do
|
|
126
|
+
ticket[:started] = true if @worker.equal?(ticket)
|
|
127
|
+
end
|
|
128
|
+
if started
|
|
129
|
+
begin
|
|
130
|
+
drain(ticket)
|
|
131
|
+
rescue => error
|
|
132
|
+
fail_worker(ticket, error)
|
|
133
|
+
ensure
|
|
134
|
+
finish_worker(ticket)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end.on_complete do |_value, error|
|
|
138
|
+
fail_worker(ticket, error) if error
|
|
139
|
+
end
|
|
140
|
+
rescue => error
|
|
141
|
+
fail_worker(ticket, error)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def drain(ticket)
|
|
145
|
+
loop do
|
|
146
|
+
batch = @mutex.synchronize do
|
|
147
|
+
if @queue.empty?
|
|
148
|
+
# Publish must observe queue emptiness and ownership release together.
|
|
149
|
+
release_worker(ticket)
|
|
150
|
+
nil
|
|
151
|
+
else
|
|
152
|
+
@queue.shift(@batch_size)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
return unless batch
|
|
156
|
+
prepared = @prepare_batch ? @prepare_batch.call(batch.freeze) : batch
|
|
157
|
+
raise ArgumentError, "prepare_batch must return an Array" unless prepared.is_a?(Array)
|
|
158
|
+
immutable_copy(prepared).each { |payload| @deliver.call(payload) }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def fail_worker(ticket, error)
|
|
163
|
+
@mutex.synchronize do
|
|
164
|
+
@failure ||= error
|
|
165
|
+
@accepting = false
|
|
166
|
+
@queue.clear
|
|
167
|
+
# Logical cancellation can precede physical worker exit. Keep waiting
|
|
168
|
+
# for a started delivery; never mistake settled TaskResult for exit.
|
|
169
|
+
release_worker(ticket) unless ticket[:started]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def finish_worker(ticket)
|
|
174
|
+
@mutex.synchronize { release_worker(ticket) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def release_worker(ticket)
|
|
178
|
+
return unless @worker.equal?(ticket)
|
|
179
|
+
@worker = nil
|
|
180
|
+
@condition.broadcast
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def immutable_copy(value, ancestors = {})
|
|
184
|
+
case value
|
|
185
|
+
when Hash, Array
|
|
186
|
+
raise ArgumentError, "event payload must contain acyclic plain data" if ancestors[value.object_id]
|
|
187
|
+
path = ancestors.merge(value.object_id => true)
|
|
188
|
+
if value.is_a?(Hash)
|
|
189
|
+
value.to_h { |key, child| [immutable_copy(key, path), immutable_copy(child, path)] }.freeze
|
|
190
|
+
else
|
|
191
|
+
value.map { |child| immutable_copy(child, path) }.freeze
|
|
192
|
+
end
|
|
193
|
+
when String then value.dup.freeze
|
|
194
|
+
when Integer, Symbol, TrueClass, FalseClass, NilClass then value
|
|
195
|
+
when Float
|
|
196
|
+
raise ArgumentError, "event payload numbers must be finite" unless value.finite?
|
|
197
|
+
value
|
|
198
|
+
else
|
|
199
|
+
raise ArgumentError, "event payload must contain plain data: #{value.class}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def monotonic_now
|
|
204
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -12,7 +12,7 @@ module Phronomy
|
|
|
12
12
|
Phronomy::Persistence::StorageBoundary.call do
|
|
13
13
|
raise Phronomy::Storage::SerializationError, "create_active requires an active TeamExecution" unless execution.active?
|
|
14
14
|
record = Codec.encode_team_execution(execution)
|
|
15
|
-
admission do
|
|
15
|
+
admission(execution.team_id) do
|
|
16
16
|
@view.atomic do |bound|
|
|
17
17
|
entry = bound.records(Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS).insert(key: execution.team_execution_id.to_s,
|
|
18
18
|
revision: Integer(execution.execution_revision), attributes: attributes(execution), record: record)
|
|
@@ -42,7 +42,7 @@ module Phronomy
|
|
|
42
42
|
raise Phronomy::Storage::SerializationError, "Execution identity mismatch"
|
|
43
43
|
end
|
|
44
44
|
record = Codec.encode_team_execution(execution)
|
|
45
|
-
admission do
|
|
45
|
+
admission(execution.team_id) do
|
|
46
46
|
@view.atomic do |bound|
|
|
47
47
|
entry = bound.records(Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS).replace(key: team_execution_id.to_s,
|
|
48
48
|
expected_revision: expected, next_revision: revision, attributes: attributes(execution),
|
|
@@ -92,7 +92,7 @@ module Phronomy
|
|
|
92
92
|
conditions: [condition])
|
|
93
93
|
rescue Phronomy::Storage::ConditionFailedError => error
|
|
94
94
|
raise unless error.condition.equal?(condition)
|
|
95
|
-
raise Phronomy::
|
|
95
|
+
raise Phronomy::MultiAgent::TeamBusyError.new(error.message, team_id: team_id), cause: error
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
|
|
@@ -100,11 +100,11 @@ module Phronomy
|
|
|
100
100
|
|
|
101
101
|
def attributes(execution) = {owner: execution.team_id.to_s, active: execution.active?}
|
|
102
102
|
|
|
103
|
-
def admission
|
|
103
|
+
def admission(team_id)
|
|
104
104
|
yield
|
|
105
105
|
rescue Phronomy::Storage::UniqueConstraintError => error
|
|
106
106
|
raise unless error.resource == Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS.id && error.constraint == :one_active_owner
|
|
107
|
-
raise Phronomy::
|
|
107
|
+
raise Phronomy::MultiAgent::TeamBusyError.new(error.message, team_id: team_id), cause: error
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def decode(entry, team_execution_id, owner: nil, revision: nil, active: nil)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../common/error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Describes the Team itself, not a child Agent.
|
|
8
|
+
# @api public
|
|
9
|
+
class TeamBusyError < Phronomy::Error
|
|
10
|
+
attr_reader :team_id
|
|
11
|
+
|
|
12
|
+
def initialize(message = nil, team_id:)
|
|
13
|
+
@team_id = team_id.to_s.dup.freeze
|
|
14
|
+
super(message || "Team #{@team_id}: busy", code: "team.busy")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -188,11 +188,22 @@ module Phronomy
|
|
|
188
188
|
begin
|
|
189
189
|
store.transaction { |tx| tx.teams.create(intended) }
|
|
190
190
|
rescue => error
|
|
191
|
-
|
|
191
|
+
begin
|
|
192
|
+
confirmed = store.teams.load(id)
|
|
193
|
+
rescue
|
|
194
|
+
# A failed readback cannot turn an unknown create outcome into
|
|
195
|
+
# a claim that the Team is absent or safe to create again.
|
|
196
|
+
raise error, cause: error.cause
|
|
197
|
+
end
|
|
192
198
|
raise error unless confirmed.to_h == intended.to_h
|
|
193
199
|
end
|
|
194
200
|
end
|
|
195
|
-
|
|
201
|
+
begin
|
|
202
|
+
root = store.teams.load(id)
|
|
203
|
+
rescue Phronomy::Persistence::NotFoundError => error
|
|
204
|
+
raise if create || error.is_a?(TeamNotFoundError)
|
|
205
|
+
raise TeamNotFoundError.new(team_id: id), cause: error
|
|
206
|
+
end
|
|
196
207
|
unless root.team_definition_id == definition.fetch(:id) && root.team_definition_version == definition.fetch(:version)
|
|
197
208
|
raise Phronomy::ConfigurationError, "Team #{id} definition mismatch"
|
|
198
209
|
end
|
|
@@ -238,7 +249,7 @@ module Phronomy
|
|
|
238
249
|
begin
|
|
239
250
|
persistence.transaction do |tx, scope|
|
|
240
251
|
root = tx.teams.load(team_id)
|
|
241
|
-
raise
|
|
252
|
+
raise TeamBusyError.new("Team #{team_id} has an active run; use resume", team_id: team_id) unless tx.team_executions.list_active(team_id).empty?
|
|
242
253
|
intended = TeamExecution.new(team_execution_id: id, team_id: team_id,
|
|
243
254
|
execution_revision: 0, status: "active", phase: "coordinator", input_ref: tx.contents.put_text(input),
|
|
244
255
|
coordinator: {"agent_id" => SecureRandom.uuid, "execution_id" => SecureRandom.uuid, "state" => "reserved"},
|
|
@@ -341,7 +352,7 @@ module Phronomy
|
|
|
341
352
|
output = Phronomy::Values::Serializable.convert(aggregate ? aggregate.call(values) : values, unsupported_message: "Team result is not canonically serializable")
|
|
342
353
|
Phronomy::CanonicalJSON.dump(output)
|
|
343
354
|
rescue => error
|
|
344
|
-
return terminal_value(finish_error(id,
|
|
355
|
+
return terminal_value(finish_error(id, Phronomy::Error.diagnostic(error)))
|
|
345
356
|
end
|
|
346
357
|
# Aggregate may be replayed only until the canonical outcome commits.
|
|
347
358
|
completed = update(id) do |fresh, tx, scope|
|
|
@@ -465,7 +476,7 @@ module Phronomy
|
|
|
465
476
|
raise Phronomy::CancellationError, "Team run #{execution.team_execution_id} cancelled" if execution.status == "cancelled"
|
|
466
477
|
if execution.error_ref
|
|
467
478
|
failure = persistence.contents.fetch_json(execution.error_ref)
|
|
468
|
-
raise Phronomy::Error
|
|
479
|
+
raise Phronomy::Error.from_diagnostic(failure)
|
|
469
480
|
end
|
|
470
481
|
persistence.contents.fetch_json(execution.result_ref)
|
|
471
482
|
end
|
|
@@ -514,7 +525,7 @@ module Phronomy
|
|
|
514
525
|
Phronomy::Execution.submit(on_full: :raise) do
|
|
515
526
|
team.send(:apply_operation, run_id, key, operation, arguments)
|
|
516
527
|
rescue OperationRejected => error
|
|
517
|
-
raise Phronomy::ConfigurationError, error.
|
|
528
|
+
raise Phronomy::ConfigurationError.new(error.message, code: error.code)
|
|
518
529
|
rescue Phronomy::CancellationError
|
|
519
530
|
raise
|
|
520
531
|
rescue => error
|
|
@@ -551,7 +562,9 @@ module Phronomy
|
|
|
551
562
|
name = entry.name
|
|
552
563
|
values = entry.arguments
|
|
553
564
|
if name == "enqueue_task"
|
|
554
|
-
|
|
565
|
+
if metadata["finalized"]
|
|
566
|
+
raise OperationRejected.new("Cannot enqueue after finalize", code: "team.enqueue_after_finalize")
|
|
567
|
+
end
|
|
555
568
|
task = {"id" => Digest::SHA256.hexdigest(entry_id)[0, 32], "description" => values.fetch("description"), "metadata" => values["metadata"]}
|
|
556
569
|
tasks << task
|
|
557
570
|
output = "Task ##{tasks.length} enqueued: #{task.fetch("description")}"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/errors"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Describes the Team itself, not a child Agent.
|
|
8
|
+
# @api public
|
|
9
|
+
class TeamNotFoundError < Phronomy::Persistence::NotFoundError
|
|
10
|
+
attr_reader :team_id
|
|
11
|
+
|
|
12
|
+
def initialize(message = nil, team_id:)
|
|
13
|
+
@team_id = team_id.to_s.dup.freeze
|
|
14
|
+
super(message || "Team #{@team_id}: not_found", code: "team.not_found")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -7,9 +7,9 @@ module Phronomy
|
|
|
7
7
|
class ConditionFailedError < ConflictError
|
|
8
8
|
attr_reader :condition
|
|
9
9
|
# @api public
|
|
10
|
-
def initialize(condition)
|
|
10
|
+
def initialize(condition, code: nil)
|
|
11
11
|
@condition = condition
|
|
12
|
-
super("storage condition failed: #{condition.class.name}")
|
|
12
|
+
super("storage condition failed: #{condition.class.name}", code: code)
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -7,10 +7,10 @@ module Phronomy
|
|
|
7
7
|
class UniqueConstraintError < ConflictError
|
|
8
8
|
attr_reader :resource, :constraint
|
|
9
9
|
# @api public
|
|
10
|
-
def initialize(resource:, constraint:)
|
|
10
|
+
def initialize(resource:, constraint:, code: nil)
|
|
11
11
|
@resource = resource.id
|
|
12
12
|
@constraint = constraint
|
|
13
|
-
super("unique constraint #{resource.id}.#{constraint} failed")
|
|
13
|
+
super("unique constraint #{resource.id}.#{constraint} failed", code: code)
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -84,7 +84,7 @@ RSpec.shared_examples "a Team execution repository" do
|
|
|
84
84
|
it "atomically admits at most one active run per Team" do
|
|
85
85
|
stores.multi_agent.team_executions.create_active(coordination_run)
|
|
86
86
|
other = coordination_run.with(team_execution_id: SecureRandom.uuid, execution_revision: 0)
|
|
87
|
-
expect { stores.multi_agent.team_executions.create_active(other) }.to raise_error(Phronomy::
|
|
87
|
+
expect { stores.multi_agent.team_executions.create_active(other) }.to raise_error(Phronomy::MultiAgent::TeamBusyError)
|
|
88
88
|
expect(stores.multi_agent.team_executions.list_active(coordination_team.team_id).map(&:team_execution_id)).to eq([coordination_run.team_execution_id])
|
|
89
89
|
end
|
|
90
90
|
|
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -383,8 +383,9 @@ module Phronomy
|
|
|
383
383
|
if schema_error
|
|
384
384
|
case self.class.on_schema_error
|
|
385
385
|
when :raise
|
|
386
|
-
raise Phronomy::ToolError
|
|
387
|
-
"#{self.class.name} schema error: #{schema_error}"
|
|
386
|
+
raise Phronomy::ToolError.new(
|
|
387
|
+
"#{self.class.name} schema error: #{schema_error}", code: "tool.invalid_arguments"
|
|
388
|
+
)
|
|
388
389
|
else
|
|
389
390
|
return "Schema validation failed: #{schema_error}"
|
|
390
391
|
end
|
|
@@ -399,17 +400,12 @@ module Phronomy
|
|
|
399
400
|
raise
|
|
400
401
|
rescue => error
|
|
401
402
|
if self.class.on_error == :suppress
|
|
402
|
-
|
|
403
|
-
"#{error.class}: #{error.message}"
|
|
404
|
-
if Phronomy::RuntimeSettings.current.logger
|
|
405
|
-
Phronomy::RuntimeSettings.current.logger.warn(msg)
|
|
406
|
-
else
|
|
407
|
-
warn msg
|
|
408
|
-
end
|
|
403
|
+
report_suppressed_error(error)
|
|
409
404
|
"Tool error suppressed: #{error.message}"
|
|
410
405
|
else
|
|
411
|
-
raise Phronomy::ToolError
|
|
412
|
-
"#{self.class.name} execution failed: #{error.message}"
|
|
406
|
+
raise Phronomy::ToolError.new(
|
|
407
|
+
"#{self.class.name} execution failed: #{error.message}", code: "tool.execution_failed"
|
|
408
|
+
), cause: error
|
|
413
409
|
end
|
|
414
410
|
end
|
|
415
411
|
|
|
@@ -462,7 +458,9 @@ module Phronomy
|
|
|
462
458
|
if schema_error
|
|
463
459
|
return Phronomy::AsyncOperation.capture(name: "#{operation_name}-schema") do
|
|
464
460
|
if self.class.on_schema_error == :raise
|
|
465
|
-
raise Phronomy::ToolError
|
|
461
|
+
raise Phronomy::ToolError.new(
|
|
462
|
+
"#{self.class.name} schema error: #{schema_error}", code: "tool.invalid_arguments"
|
|
463
|
+
)
|
|
466
464
|
end
|
|
467
465
|
"Schema validation failed: #{schema_error}"
|
|
468
466
|
end
|
|
@@ -472,8 +470,9 @@ module Phronomy
|
|
|
472
470
|
transform: method(:truncate_result_if_needed)) do
|
|
473
471
|
source = yield(validated_args || {})
|
|
474
472
|
unless source.respond_to?(:on_complete)
|
|
475
|
-
raise Phronomy::ToolError
|
|
476
|
-
"#{self.class.name} asynchronous execution must return a completion handle"
|
|
473
|
+
raise Phronomy::ToolError.new(
|
|
474
|
+
"#{self.class.name} asynchronous execution must return a completion handle", code: "tool.invalid_async_result"
|
|
475
|
+
)
|
|
477
476
|
end
|
|
478
477
|
source
|
|
479
478
|
end
|
|
@@ -488,23 +487,26 @@ module Phronomy
|
|
|
488
487
|
end
|
|
489
488
|
|
|
490
489
|
if self.class.on_error == :suppress
|
|
491
|
-
|
|
492
|
-
"#{error.class}: #{error.message}"
|
|
493
|
-
if Phronomy::RuntimeSettings.current.logger
|
|
494
|
-
Phronomy::RuntimeSettings.current.logger.warn(message)
|
|
495
|
-
else
|
|
496
|
-
warn message
|
|
497
|
-
end
|
|
490
|
+
report_suppressed_error(error)
|
|
498
491
|
"Tool error suppressed: #{error.message}"
|
|
499
492
|
else
|
|
500
493
|
wrapped = Phronomy::ToolError.new(
|
|
501
|
-
"#{self.class.name} execution failed: #{error.message}"
|
|
494
|
+
"#{self.class.name} execution failed: #{error.message}", code: "tool.execution_failed"
|
|
502
495
|
)
|
|
503
496
|
wrapped.set_backtrace(error.backtrace)
|
|
504
|
-
raise wrapped
|
|
497
|
+
raise wrapped, cause: error
|
|
505
498
|
end
|
|
506
499
|
end
|
|
507
500
|
|
|
501
|
+
def report_suppressed_error(error)
|
|
502
|
+
message = "[Phronomy] Tool #{self.class.name} suppressed error: #{error.class}: #{error.message}"
|
|
503
|
+
logger = Phronomy::RuntimeSettings.current.logger
|
|
504
|
+
logger ? logger.warn(message) : warn(message)
|
|
505
|
+
rescue
|
|
506
|
+
# A diagnostic failure must not change the selected suppression policy.
|
|
507
|
+
nil
|
|
508
|
+
end
|
|
509
|
+
|
|
508
510
|
def execute_accepts_cancellation_token?
|
|
509
511
|
method(:execute).parameters.any? do |type, name|
|
|
510
512
|
name == :cancellation_token && %i[key keyreq].include?(type)
|
|
@@ -34,15 +34,44 @@ module Phronomy
|
|
|
34
34
|
# @return [Object] the block's return value
|
|
35
35
|
# @api public
|
|
36
36
|
def trace(name, input: nil, **meta)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
Base.trace_with(self, name, attributes: {input: input, **meta}) { |span| yield span }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Use the public start/finish SPI, including duck-typed tracers.
|
|
41
|
+
# Start failures do not finish; finish is attempted once after execution.
|
|
42
|
+
# A secondary finish/reporting failure cannot replace the block's error.
|
|
43
|
+
# Set record_output: false for metadata-only operations.
|
|
44
|
+
# @api public
|
|
45
|
+
def self.trace_with(tracer, name, record_output: true, attributes: {})
|
|
46
|
+
span = tracer.start_span(name, **attributes)
|
|
47
|
+
begin
|
|
48
|
+
result, usage = yield span
|
|
49
|
+
rescue => primary
|
|
50
|
+
begin
|
|
51
|
+
tracer.finish_span(span, error: primary)
|
|
52
|
+
rescue => secondary
|
|
53
|
+
report_secondary(secondary)
|
|
54
|
+
end
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
if record_output
|
|
58
|
+
tracer.finish_span(span, output: result, usage: usage)
|
|
59
|
+
else
|
|
60
|
+
tracer.finish_span(span)
|
|
61
|
+
end
|
|
40
62
|
result
|
|
41
|
-
rescue => e
|
|
42
|
-
finish_span(span, error: e)
|
|
43
|
-
raise
|
|
44
63
|
end
|
|
45
64
|
|
|
65
|
+
def self.report_secondary(error)
|
|
66
|
+
Phronomy::RuntimeSettings.current.logger&.warn(
|
|
67
|
+
"[Phronomy::Tracing] secondary finish failure: #{error.class}: #{error.message}"
|
|
68
|
+
)
|
|
69
|
+
rescue
|
|
70
|
+
# Diagnostics are best effort; preserve the primary business failure.
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
private_class_method :report_secondary
|
|
74
|
+
|
|
46
75
|
# Start a new span. Must return an object that will be passed to #finish_span.
|
|
47
76
|
#
|
|
48
77
|
# @param name [String]
|