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,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Owns the Agents materialized within one application operation. A scope is
6
+ # Runtime-bound, process-local, and must not be persisted as Workflow state.
7
+ # @api public
8
+ class Scope
9
+ # @api private
10
+ def self.open
11
+ raise ArgumentError, "open_scope requires a block" unless block_given?
12
+ if Phronomy::WaitPolicy.blocking_forbidden?
13
+ raise Phronomy::EventLoopReentrancyError, "Agent.open_scope cannot block EventLoop"
14
+ end
15
+
16
+ scope = new(environment: ExecutionEnvironment.current)
17
+ result = nil
18
+ begin
19
+ result = yield(scope)
20
+ result = result.wait_result if result.is_a?(Phronomy::TaskResult)
21
+ rescue Exception => primary # standard:disable Lint/RescueException
22
+ raise
23
+ ensure
24
+ failures = scope.send(:__close)
25
+ if primary
26
+ failures.each do |failure|
27
+ error = failure.fetch(:error)
28
+ Phronomy::RuntimeSettings.current.logger&.warn(
29
+ "[Agent scope] cleanup failed agent_id=#{failure.fetch(:agent_id).inspect}: #{error.class}: #{error.message}"
30
+ )
31
+ rescue
32
+ nil
33
+ end
34
+ elsif failures.any?
35
+ raise ScopeCleanupError.new(failures: failures, result: result)
36
+ end
37
+ end
38
+ result
39
+ end
40
+ private_class_method :open
41
+
42
+ def initialize(environment:)
43
+ @environment = environment
44
+ @ownership = environment.ownership
45
+ @owner_token = Object.new.freeze
46
+ @mutex = Mutex.new
47
+ @condition = ConditionVariable.new
48
+ @state = :open
49
+ @receipts = {}
50
+ @agents = {}
51
+ end
52
+ private_class_method :new
53
+
54
+ # @return [Agent::Base]
55
+ # @api public
56
+ def create(agent_class, **options, &listener)
57
+ check_blocking!
58
+ materialize(:create, agent_class, options, listener, async: false)
59
+ end
60
+
61
+ # @return [TaskResult<Agent::Base>]
62
+ # @api public
63
+ def create_async(agent_class, **options, &listener)
64
+ materialize(:create, agent_class, options, listener, async: true)
65
+ rescue => error
66
+ Phronomy::TaskResult.failed(error)
67
+ end
68
+
69
+ # Repeated load in this scope returns the same live owner. Supplying a new
70
+ # listener or different Store for an already live instance is rejected.
71
+ # @return [Agent::Base]
72
+ # @api public
73
+ def load(agent_class, agent_id, persistence:, **options, &listener)
74
+ check_blocking!
75
+ materialize(:load, agent_class, options.merge(agent_id: agent_id, persistence: persistence), listener, async: false)
76
+ end
77
+
78
+ # @return [TaskResult<Agent::Base>]
79
+ # @api public
80
+ def load_async(agent_class, agent_id, persistence:, **options, &listener)
81
+ materialize(:load, agent_class, options.merge(agent_id: agent_id, persistence: persistence), listener, async: true)
82
+ rescue => error
83
+ Phronomy::TaskResult.failed(error)
84
+ end
85
+
86
+ # @api private
87
+ def __close
88
+ agents = @mutex.synchronize do
89
+ @state = :closing
90
+ @condition.wait(@mutex) until @receipts.empty?
91
+ @agents.values.dup
92
+ end
93
+ failures = []
94
+ agents.each do |agent|
95
+ agent.unload!
96
+ rescue => error
97
+ failures << {agent_id: agent.agent_id, error: error}.freeze
98
+ end
99
+ @mutex.synchronize do
100
+ @agents.clear
101
+ @environment = @ownership = @owner_token = nil
102
+ @state = :closed
103
+ end
104
+ failures.freeze
105
+ end
106
+ private :__close
107
+
108
+ private
109
+
110
+ def check_blocking!
111
+ return unless Phronomy::WaitPolicy.blocking_forbidden?
112
+
113
+ raise Phronomy::EventLoopReentrancyError, "Agent scope materialization cannot block EventLoop; use create_async/load_async"
114
+ end
115
+
116
+ def materialize(kind, agent_class, options, listener, async:)
117
+ unless agent_class.is_a?(Class) && agent_class <= Base
118
+ raise ArgumentError, "agent_class must be an Agent::Base subclass"
119
+ end
120
+ if options[:on_event] && listener
121
+ raise ArgumentError, "Provide either on_event: or a block, not both"
122
+ end
123
+ if kind == :load && !options[:persistence]
124
+ raise ArgumentError, "persistence is required"
125
+ end
126
+ key = (options[:agent_id] || (SecureRandom.uuid if kind == :create)).to_s.dup.freeze
127
+ raise ArgumentError, "agent_id must not be empty" if key.empty?
128
+ options = options.merge(agent_id: key)
129
+ receipt = Object.new.freeze
130
+ @mutex.synchronize do
131
+ raise Phronomy::Error, "Agent scope is #{@state}" unless @state == :open
132
+ @receipts[receipt] = key
133
+ end
134
+ operation = -> { acquire(kind, agent_class, key, options, listener) }
135
+ if async
136
+ begin
137
+ task = @environment.submit(on_full: :raise, &operation)
138
+ task.on_physical_complete { finish_receipt(receipt) }
139
+ task
140
+ rescue
141
+ finish_receipt(receipt)
142
+ raise
143
+ end
144
+ else
145
+ begin
146
+ operation.call
147
+ ensure
148
+ finish_receipt(receipt)
149
+ end
150
+ end
151
+ end
152
+
153
+ def acquire(kind, agent_class, key, options, listener)
154
+ materialized = false
155
+ agent = @ownership.public_send(kind, key, expected_class: agent_class, scope_owner: @owner_token) do |environment|
156
+ materialized = true
157
+ instance = agent_class.send(:__construct_owned_agent, environment, key,
158
+ **options, load_existing: kind == :load, &listener)
159
+ RecoveryCoordinator.new(instance).recover_on_load! if kind == :load
160
+ instance
161
+ end
162
+ # Register before any result or post-materialization check reaches the
163
+ # caller, even when scope closure began while the worker was running.
164
+ @mutex.synchronize { @agents[agent.object_id] = agent }
165
+ if kind == :load
166
+ unless agent.persistence.equal?(options[:persistence])
167
+ raise Phronomy::ConfigurationError, "Agent #{key.inspect} is already live with a different Persistence instance"
168
+ end
169
+ if !materialized && (options[:on_event] || listener)
170
+ raise Phronomy::ConfigurationError, "Agent #{key.inspect} is already live; load cannot re-bind on_event"
171
+ end
172
+ end
173
+ agent
174
+ rescue Phronomy::Persistence::ConflictError => error
175
+ raise unless kind == :create
176
+ raise Phronomy::AgentAlreadyExistsError, "Agent #{key.inspect} already exists durably: #{error.message}"
177
+ rescue => error
178
+ # A fail-closed construction remains reserved in C04. Do not purge or
179
+ # release that reservation just because no instance reached this scope.
180
+ report_materialization_error(key, error)
181
+ raise
182
+ end
183
+
184
+ def finish_receipt(receipt)
185
+ @mutex.synchronize do
186
+ @receipts.delete(receipt)
187
+ @condition.broadcast
188
+ end
189
+ end
190
+
191
+ def report_materialization_error(key, error)
192
+ Phronomy::RuntimeSettings.current.logger&.warn("[Agent scope] materialization failed agent_id=#{key.inspect}: #{error.class}: #{error.message}")
193
+ rescue
194
+ nil
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Successful application work with one or more failed Agent releases.
6
+ # Original exceptions and the successful result remain available to the caller.
7
+ # @api public
8
+ class ScopeCleanupError < Phronomy::Error
9
+ attr_reader :failures, :result
10
+
11
+ def initialize(failures:, result:)
12
+ @failures = failures.map { |failure| failure.dup.freeze }.freeze
13
+ @result = result
14
+ super("Agent scope cleanup failed for: #{@failures.map { |f| f.fetch(:agent_id) }.join(", ")}",
15
+ code: "agent.scope_cleanup_failed")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Agent-owned two-phase release. The worker only checks durable facts; the
6
+ # EventLoop completes the exact process-local reservation after physical I/O.
7
+ # @api private
8
+ class UnloadTransition
9
+ Command = Data.define(:coordinator, :phase)
10
+ private_constant :Command
11
+
12
+ attr_reader :result
13
+
14
+ def initialize(agent:, environment:, ownership:)
15
+ @agent, @environment, @ownership = agent, environment, ownership
16
+ @result = Phronomy::TaskResult.deferred(name: "agent-unload:#{agent.agent_id}")
17
+ @mutex = Mutex.new
18
+ @finished = false
19
+ end
20
+
21
+ def start
22
+ post(:check)
23
+ @result
24
+ end
25
+
26
+ def deliver_on_event_loop(command)
27
+ if command.phase == :check
28
+ if @environment.existing_registry&.agent_execution_admitted?(@agent.agent_id)
29
+ raise Phronomy::AgentBusyError, "Agent #{@agent.agent_id.inspect} has a nonterminal execution"
30
+ end
31
+ agent_id, persistence = @agent.agent_id, @agent.persistence
32
+ task = @environment.submit(on_full: :raise) do
33
+ persistence.transaction do |tx|
34
+ tx.executions.assert_idle!(agent_id)
35
+ tx.guard_agent!(agent_id)
36
+ unless tx.retentions.list(agent_id).empty?
37
+ raise Phronomy::AgentBusyError, "Agent #{agent_id.inspect} is retained by durable coordination"
38
+ end
39
+ end
40
+ end
41
+ task.on_complete do |_value, error|
42
+ @read_error = error
43
+ task.on_physical_complete { post(:apply) }
44
+ end
45
+ else
46
+ finish(@read_error)
47
+ end
48
+ rescue => error
49
+ finish(error)
50
+ end
51
+
52
+ private
53
+
54
+ def post(phase)
55
+ accepted = @environment.registry.post(Command.new(coordinator: self, phase: phase), completion: @result)
56
+ finish(Phronomy::RuntimeShutdownError.new("EventLoop rejected Agent unload #{phase}")) unless accepted
57
+ rescue => error
58
+ finish(error)
59
+ end
60
+
61
+ def finish(error)
62
+ first = @mutex.synchronize do
63
+ next false if @finished
64
+ @finished = true
65
+ end
66
+ return unless first
67
+
68
+ begin
69
+ @ownership.finish_unload(@agent, self, success: error.nil?)
70
+ rescue => ownership_error
71
+ error ||= ownership_error
72
+ end
73
+ @agent = @environment = @ownership = nil
74
+ error ? @result.fail(error) : @result.complete(true)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -52,9 +52,9 @@ module Phronomy
52
52
  environment.registry.mark_agent_execution_admission(agent.agent_id,
53
53
  execution_id: execution.execution_id, state: :recovery_required)
54
54
  deliver_resolution_required(execution, coordination_recovery_descriptor(execution))
55
- completion.complete({execution_id: execution.execution_id,
56
- execution_revision: execution.execution_revision,
57
- recovery: :resolution_required}.freeze)
55
+ agent.send(:_complete_result_task, completion, {execution_id: execution.execution_id,
56
+ execution_revision: execution.execution_revision,
57
+ recovery: :resolution_required}.freeze)
58
58
  return
59
59
  end
60
60
 
@@ -38,7 +38,7 @@ module Phronomy
38
38
  end
39
39
 
40
40
  def prepare_plan(execution)
41
- root = agent.agent_root
41
+ root = agent.send(:__root_for_operation)
42
42
  manifest_ref = execution.metadata["manifest_ref"]
43
43
  base_ref = execution.metadata["base_manifest_ref"] ||
44
44
  manifest_ref
@@ -209,7 +209,7 @@ module Phronomy
209
209
  }
210
210
  )
211
211
  end
212
- command.completion.complete(agent)
212
+ agent.send(:_complete_result_task, command.completion, agent)
213
213
  return
214
214
  end
215
215
 
@@ -246,7 +246,7 @@ module Phronomy
246
246
  result: payload
247
247
  )
248
248
  end
249
- command.completion.complete(agent)
249
+ agent.send(:_complete_result_task, command.completion, agent)
250
250
  when Phronomy::Agent::RecoveryRules::RESUMABLE
251
251
  continue_resumable_on_event_loop(
252
252
  execution,
@@ -288,7 +288,7 @@ module Phronomy
288
288
  end
289
289
  end
290
290
  end
291
- command.completion.fail(caught)
291
+ agent.send(:_fail_result_task, command.completion, caught)
292
292
  # simplecov:enable
293
293
  end
294
294
 
@@ -317,7 +317,7 @@ module Phronomy
317
317
  if framework_batch?(execution)
318
318
  internal = Phronomy::TaskResult.deferred(name: "framework-tool-recovery:#{execution.execution_id}")
319
319
  continue_recovery_on_event_loop(execution, internal, material: material)
320
- completion.complete(agent)
320
+ agent.send(:_complete_result_task, completion, agent)
321
321
  return
322
322
  end
323
323
 
@@ -359,11 +359,11 @@ module Phronomy
359
359
  result_task: internal_task,
360
360
  error: nil
361
361
  ))
362
- completion.complete(agent)
362
+ agent.send(:_complete_result_task, completion, agent)
363
363
  when :recovery_tools_completed, :recovery_provider_completed, :recovery_resolved_failed
364
364
  internal_task = Phronomy::TaskResult.deferred(name: "agent-recovery-auto:#{execution.execution_id}")
365
365
  continue_recovery_on_event_loop(execution, internal_task, material: material)
366
- completion.complete(agent)
366
+ agent.send(:_complete_result_task, completion, agent)
367
367
  else
368
368
  raise Phronomy::ExecutionRehydrationRequiredError,
369
369
  "no automatic continuation for #{execution.phase.inspect}"
@@ -14,24 +14,22 @@ module Phronomy
14
14
  request.execution_id
15
15
  )
16
16
  unless state && state.agent.equal?(agent)
17
- request.completion.fail(
17
+ agent.send(:_fail_result_task, request.completion,
18
18
  Phronomy::ExecutionRehydrationRequiredError.new(
19
19
  "no live recovered execution #{request.execution_id}"
20
- )
21
- )
20
+ ))
22
21
  return
23
22
  end
24
23
 
25
24
  current = state.execution
26
25
  unless current.execution_revision ==
27
26
  request.expected_execution_revision
28
- request.completion.fail(
27
+ agent.send(:_fail_result_task, request.completion,
29
28
  Phronomy::Persistence::StateConflictError.new(
30
29
  "Recovery resolution revision conflict: expected " \
31
30
  "#{request.expected_execution_revision}, actual " \
32
31
  "#{current.execution_revision}"
33
- )
34
- )
32
+ ))
35
33
  return
36
34
  end
37
35
 
@@ -42,28 +40,26 @@ module Phronomy
42
40
  descriptor.fetch(:subject),
43
41
  request.subject
44
42
  )
45
- request.completion.fail(
43
+ agent.send(:_fail_result_task, request.completion,
46
44
  ArgumentError.new(
47
45
  "Recovery subject is not current for execution #{request.execution_id}"
48
- )
49
- )
46
+ ))
50
47
  return
51
48
  end
52
49
 
53
50
  unless Array(
54
51
  descriptor.fetch(:allowed_outcomes)
55
52
  ).map(&:to_sym).include?(request.outcome)
56
- request.completion.fail(
53
+ agent.send(:_fail_result_task, request.completion,
57
54
  ArgumentError.new(
58
55
  "Recovery outcome #{request.outcome.inspect} is not allowed for the current subject"
59
- )
60
- )
56
+ ))
61
57
  return
62
58
  end
63
59
 
64
60
  operation = ResolutionOperation.new(
65
61
  execution: current,
66
- root: agent.agent_root,
62
+ root: agent.send(:__root_for_operation),
67
63
  subject: request.subject,
68
64
  outcome: request.outcome,
69
65
  result: request.result,
@@ -77,6 +73,7 @@ module Phronomy
77
73
  task = @environment.submit(on_full: :raise) do
78
74
  prepare_resolution(operation)
79
75
  end
76
+ request.completion.track(task) if request.completion.respond_to?(:track)
80
77
  task.on_complete do |result, error|
81
78
  ready = ResolveReady.new(
82
79
  coordinator: self,
@@ -86,15 +83,14 @@ module Phronomy
86
83
  error: error
87
84
  )
88
85
  unless post_control(ready)
89
- request.completion.fail(
86
+ agent.send(:_fail_result_task, request.completion,
90
87
  Phronomy::RuntimeShutdownError.new(
91
88
  "EventLoop rejected Recovery resolution apply"
92
- )
93
- )
89
+ ))
94
90
  end
95
91
  end
96
92
  rescue => caught
97
- request.completion.fail(caught)
93
+ agent.send(:_fail_result_task, request.completion, caught)
98
94
  end
99
95
 
100
96
  def perform_resolution(operation)
@@ -498,7 +494,7 @@ module Phronomy
498
494
 
499
495
  continue_recovery_on_event_loop(result.execution, request.completion, material: result.material)
500
496
  rescue => caught
501
- request.completion.fail(caught)
497
+ agent.send(:_fail_result_task, request.completion, caught)
502
498
  end
503
499
  end
504
500
  end
@@ -147,9 +147,8 @@ module Phronomy
147
147
  failure = error_present ?
148
148
  RecoverySupport.resolution_failure(error) : nil
149
149
 
150
- completion = Phronomy::TaskResult.deferred(
151
- name: "agent-recovery-resolve:#{execution_id}"
152
- )
150
+ completion = OperationResult.new(agent: agent,
151
+ name: "agent-recovery-resolve:#{execution_id}")
153
152
  command = ResolveCommand.new(
154
153
  coordinator: self,
155
154
  execution_id: execution_id.to_s.freeze,
@@ -162,18 +161,15 @@ module Phronomy
162
161
  completion: completion
163
162
  )
164
163
  unless post_control(command)
165
- completion.fail(
166
- Phronomy::RuntimeShutdownError.new(
167
- "EventLoop rejected Agent Recovery resolution"
168
- )
169
- )
164
+ agent.send(:_fail_result_task, completion,
165
+ Phronomy::RuntimeShutdownError.new("EventLoop rejected Agent Recovery resolution"))
170
166
  end
171
167
  completion
172
168
  rescue => caught
173
169
  completion ||= Phronomy::TaskResult.deferred(
174
170
  name: "agent-recovery-resolve:#{execution_id}"
175
171
  )
176
- completion.fail(caught)
172
+ agent.send(:_fail_result_task, completion, caught)
177
173
  completion
178
174
  end
179
175
 
@@ -26,17 +26,11 @@ module Phronomy
26
26
  end
27
27
 
28
28
  def resolution_failure(error)
29
- {
30
- "class" => error.class.name.to_s,
31
- "message" => error.message.to_s
32
- }.freeze
29
+ Phronomy::Error.diagnostic(error).freeze
33
30
  end
34
31
 
35
32
  def error_from_failure(failure)
36
- hash = failure.to_h { |key, value| [key.to_s, value] }
37
- Phronomy::Error.new(
38
- "#{hash.fetch("class", "Error")}: #{hash.fetch("message", "Recovery-resolved failure")}"
39
- )
33
+ Phronomy::Error.from_diagnostic(failure)
40
34
  end
41
35
 
42
36
  def event_payload(execution, descriptor)
@@ -22,14 +22,14 @@ module Phronomy
22
22
  # @return [Exception] original Application callback exception
23
23
  attr_reader :original_error
24
24
 
25
- def initialize(event_type:, original_error:, result: nil)
25
+ def initialize(event_type:, original_error:, result: nil, code: nil)
26
26
  @event_type = event_type
27
27
  @original_error = original_error
28
28
  @result = result
29
29
 
30
30
  super(
31
31
  "Stream callback failed while handling #{event_type.inspect}: " \
32
- "#{original_error.class}: #{original_error.message}"
32
+ "#{original_error.class}: #{original_error.message}", code: code
33
33
  )
34
34
  set_backtrace(original_error.backtrace)
35
35
  end
@@ -1,5 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- class Error < StandardError; end
4
+ class Error < StandardError
5
+ # Optional stable reason supplied by the owner of the failed operation.
6
+ # It does not imply retry safety or replace execution status.
7
+ # @api public
8
+ attr_reader :code
9
+
10
+ # Existing message-only construction retains StandardError behavior.
11
+ # @api public
12
+ def initialize(message = nil, code: nil)
13
+ raise ArgumentError, "error code must be a String or nil" unless code.nil? || code.is_a?(String)
14
+ @code = code&.dup&.freeze
15
+ super(message)
16
+ end
17
+
18
+ # Generic diagnostic transport. No domain-specific interpretation.
19
+ # External exceptions are not extended with this protocol.
20
+ # @api private
21
+ def self.diagnostic(error)
22
+ diagnostic = {"class" => error.class.name, "message" => error.message}
23
+ diagnostic["code"] = error.code if error.is_a?(Phronomy::Error) && error.code
24
+ cause = error.cause
25
+ diagnostic["cause"] = cause_diagnostic(cause, remaining: 3, seen: [error.object_id]) if cause
26
+ diagnostic
27
+ end
28
+
29
+ # Restores data, never a saved class name or arbitrary instance attributes.
30
+ # Restored causes are generic errors as well. Old diagnostics remain valid.
31
+ # @api private
32
+ def self.from_diagnostic(value, remaining: 3)
33
+ hash = value.to_h { |key, item| [key.to_s, item] }
34
+ error = Phronomy::Error.new(
35
+ "#{hash.fetch("class", "Error")}: #{hash.fetch("message", "Recovery-resolved failure")}",
36
+ code: hash["code"]
37
+ )
38
+ return error unless remaining.positive? && hash["cause"].is_a?(Hash) && hash["cause"].key?("class")
39
+
40
+ cause = from_diagnostic(hash["cause"], remaining: remaining - 1)
41
+ begin
42
+ raise error, cause: cause
43
+ rescue Phronomy::Error => restored
44
+ restored.set_backtrace(nil)
45
+ restored
46
+ end
47
+ end
48
+
49
+ def self.cause_diagnostic(error, remaining:, seen:)
50
+ return {"truncated" => true} if remaining.zero? || seen.include?(error.object_id)
51
+
52
+ # Bound newly added cause data; retain the existing top-level format.
53
+ fields = {"class" => error.class.name, "message" => error.message}
54
+ fields["code"] = error.code if error.is_a?(Phronomy::Error) && error.code
55
+ diagnostic = fields.to_h do |key, value|
56
+ [key, value.is_a?(String) ? value[0, 2048] : value]
57
+ end
58
+ # Never turn a truncated reason into a different, apparently valid code.
59
+ diagnostic.delete("code") if fields["code"] && fields["code"].length > 2048
60
+ diagnostic["truncated"] = true if diagnostic != fields
61
+ if error.cause
62
+ diagnostic["cause"] = cause_diagnostic(error.cause,
63
+ remaining: remaining - 1, seen: seen + [error.object_id])
64
+ end
65
+ diagnostic
66
+ end
67
+ private_class_method :cause_diagnostic
68
+ end
5
69
  end
@@ -28,13 +28,18 @@ module Phronomy
28
28
  def fetch_text(content_id)
29
29
  value = fetch(content_id).force_encoding(Encoding::UTF_8)
30
30
  unless value.valid_encoding?
31
- raise IntegrityError, "content is not UTF-8: #{content_id}"
31
+ raise IntegrityError.new("content is not UTF-8: #{content_id}", code: "content.invalid_encoding")
32
32
  end
33
33
  value
34
34
  end
35
35
 
36
36
  def fetch_json(content_id)
37
- Phronomy::CanonicalJSON.load(fetch(content_id))
37
+ bytes = fetch(content_id)
38
+ begin
39
+ Phronomy::CanonicalJSON.load(bytes)
40
+ rescue JSON::ParserError => error
41
+ raise IntegrityError.new("content is not JSON: #{content_id}", code: "content.invalid_json"), cause: error
42
+ end
38
43
  end
39
44
 
40
45
  def fetch_many(content_ids)
@@ -97,24 +97,17 @@ module Phronomy
97
97
  private
98
98
 
99
99
  def invoke_policy(policy, policy_input)
100
- tracer = @tracer
101
- span = tracer.start_span(
102
- "context_policy",
103
- agent_id: policy_input.agent_id,
104
- execution_id: policy_input.execution_id,
105
- call_sequence: policy_input.call_sequence,
106
- policy_class: policy.class.name || policy.class.to_s,
107
- instruction_count: policy_input.instruction.length,
108
- knowledge_count: policy_input.knowledge.length,
109
- tool_count: policy_input.tools.length,
110
- conversation_group_count: policy_input.conversation.length
111
- )
112
- result = policy.call(policy_input)
113
- tracer.finish_span(span)
114
- result
115
- rescue => error
116
- tracer&.finish_span(span, error: error) if defined?(span) && span
117
- raise
100
+ Phronomy::Tracing::Base.trace_with(
101
+ @tracer, "context_policy", record_output: false,
102
+ attributes: {agent_id: policy_input.agent_id,
103
+ execution_id: policy_input.execution_id,
104
+ call_sequence: policy_input.call_sequence,
105
+ policy_class: policy.class.name || policy.class.to_s,
106
+ instruction_count: policy_input.instruction.length,
107
+ knowledge_count: policy_input.knowledge.length,
108
+ tool_count: policy_input.tools.length,
109
+ conversation_group_count: policy_input.conversation.length}
110
+ ) { [policy.call(policy_input), nil] }
118
111
  end
119
112
 
120
113
  def store_manifest(