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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -5
  3. data/docs/application-recipes.md +229 -0
  4. data/docs/architecture/dx05-agent-scope.md +119 -0
  5. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  6. data/docs/architecture/error-api-review.md +245 -0
  7. data/docs/failure-reason-codes.md +45 -0
  8. data/docs/features.md +4 -1
  9. data/docs/getting-started.md +44 -43
  10. data/docs/ordered-event-delivery.md +54 -0
  11. data/docs/workflow-completion.md +92 -0
  12. data/gemfiles/http_minimum.gemfile +5 -0
  13. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  14. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  15. data/lib/phronomy/agent/api/agent.rb +13 -0
  16. data/lib/phronomy/agent/async_event_api.rb +111 -87
  17. data/lib/phronomy/agent/base.rb +74 -43
  18. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  19. data/lib/phronomy/agent/execution/exact_execution.rb +11 -9
  20. data/lib/phronomy/agent/execution/execution_coordinator.rb +16 -13
  21. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -1
  22. data/lib/phronomy/agent/execution/initial_preparation.rb +1 -4
  23. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +1 -4
  24. data/lib/phronomy/agent/filterable.rb +21 -15
  25. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  26. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  27. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  28. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  29. data/lib/phronomy/agent/lifecycle/scope.rb +198 -0
  30. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +19 -0
  31. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  32. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
  33. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +7 -7
  34. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  35. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +5 -9
  36. data/lib/phronomy/agent/recovery/recovery_support.rb +2 -8
  37. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  38. data/lib/phronomy/common/error.rb +65 -1
  39. data/lib/phronomy/content_store/base.rb +7 -2
  40. data/lib/phronomy/context/assembly.rb +11 -18
  41. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  42. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +5 -1
  43. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  44. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  45. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  46. data/lib/phronomy/execution/task_result.rb +7 -2
  47. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  48. data/lib/phronomy/generation/low_confidence_error.rb +2 -2
  49. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  50. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  51. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  52. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  53. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  54. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  55. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  56. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  57. data/lib/phronomy/tool/base.rb +25 -23
  58. data/lib/phronomy/tracing/base.rb +35 -6
  59. data/lib/phronomy/tracing/open_telemetry_tracer.rb +4 -29
  60. data/lib/phronomy/version.rb +1 -1
  61. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  62. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  63. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  64. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  65. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  66. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  67. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  68. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  69. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  70. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  71. data/lib/phronomy.rb +6 -0
  72. data/scripts/api_snapshot.rb +12 -0
  73. data/scripts/check_readme_ruby.rb +24 -29
  74. data/scripts/check_readme_runnable.rb +92 -81
  75. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  76. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  77. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  78. data/sig/phronomy/agent.rbs +18 -0
  79. data/sig/phronomy/embeddings/base.rbs +6 -0
  80. data/sig/phronomy/execution/execution.rbs +2 -2
  81. data/sig/phronomy/extensions.rbs +1 -0
  82. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  83. data/sig/phronomy/generation/low_confidence_error.rbs +6 -0
  84. data/sig/phronomy/integrations.rbs +14 -0
  85. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  86. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  87. data/sig/phronomy/storage/contracts.rbs +2 -2
  88. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  89. data/sig/phronomy/workflow.rbs +4 -0
  90. data/sig/phronomy.rbs +4 -0
  91. 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.message
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
- Phronomy::RuntimeSettings.current.logger&.error do
312
- "[TaskResult] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
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,8 +6,8 @@ module Phronomy
6
6
  class FilterBlockError < Error
7
7
  attr_reader :filter
8
8
 
9
- def initialize(message, filter: nil)
10
- super(message)
9
+ def initialize(message, filter: nil, code: nil)
10
+ super(message, code: code)
11
11
  @filter = filter
12
12
  end
13
13
  end
@@ -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::AgentBusyError, error.message
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::AgentBusyError, error.message
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
- confirmed = store.teams.load(id)
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
- root = store.teams.load(id)
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 Phronomy::AgentBusyError, "Team #{team_id} has an active run; use resume" unless tx.team_executions.list_active(team_id).empty?
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, {"class" => error.class.name.to_s, "message" => error.message.to_s}))
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, "#{failure.fetch("class")}: #{failure.fetch("message")}"
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.message
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
- raise OperationRejected, "Cannot enqueue after finalize" if metadata["finalized"]
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::AgentBusyError)
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
 
@@ -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
- msg = "[Phronomy] Tool #{self.class.name} suppressed error: " \
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, "#{self.class.name} schema error: #{schema_error}"
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
- message = "[Phronomy] Tool #{self.class.name} suppressed error: " \
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
- span = start_span(name, input: input, **meta)
38
- result, usage = yield span
39
- finish_span(span, output: result, usage: usage)
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]