phronomy 0.28.0 → 0.30.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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -1
  3. data/README.md +32 -6
  4. data/docs/application-recipes.md +229 -0
  5. data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
  6. data/docs/architecture/agent-iteration-budget.md +81 -0
  7. data/docs/architecture/dx05-agent-scope.md +126 -0
  8. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  9. data/docs/architecture/error-api-review.md +245 -0
  10. data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
  11. data/docs/architecture/generator-verifier-ownership.md +18 -109
  12. data/docs/architecture/llm-continuation-metadata.md +88 -0
  13. data/docs/architecture/lm-studio-output-failures.md +111 -0
  14. data/docs/architecture/r8-unit15.md +3 -0
  15. data/docs/architecture/r8-unit7.md +2 -1
  16. data/docs/architecture/structured-output-and-scope.md +183 -0
  17. data/docs/architecture/tracing.md +22 -4
  18. data/docs/architecture/transport-error-boundaries.md +103 -0
  19. data/docs/architecture/trust-acceptance-followup.md +87 -0
  20. data/docs/failure-reason-codes.md +45 -0
  21. data/docs/features.md +14 -2
  22. data/docs/getting-started.md +44 -43
  23. data/docs/migrations/r8-unit15.md +3 -0
  24. data/docs/ordered-event-delivery.md +54 -0
  25. data/docs/workflow-completion.md +92 -0
  26. data/gemfiles/http_minimum.gemfile +10 -0
  27. data/lib/phronomy/agent/admission.rb +1 -1
  28. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  29. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  30. data/lib/phronomy/agent/api/agent.rb +13 -0
  31. data/lib/phronomy/agent/async_event_api.rb +121 -88
  32. data/lib/phronomy/agent/base.rb +82 -44
  33. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  34. data/lib/phronomy/agent/context_importer.rb +4 -0
  35. data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
  36. data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
  37. data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
  38. data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
  39. data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
  40. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
  41. data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
  42. data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
  43. data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
  44. data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
  45. data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
  46. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
  47. data/lib/phronomy/agent/execution_observation.rb +5 -5
  48. data/lib/phronomy/agent/filterable.rb +21 -15
  49. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  50. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  51. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  52. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  53. data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
  54. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
  55. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  56. data/lib/phronomy/agent/output_contract.rb +64 -0
  57. data/lib/phronomy/agent/output_error.rb +53 -0
  58. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
  59. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
  60. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  61. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
  62. data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
  63. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
  64. data/lib/phronomy/agent/runtime_input.rb +15 -4
  65. data/lib/phronomy/agent/saved_context_reader.rb +2 -1
  66. data/lib/phronomy/agent/store.rb +10 -2
  67. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  68. data/lib/phronomy/common/error.rb +65 -1
  69. data/lib/phronomy/common/values/immutable.rb +33 -0
  70. data/lib/phronomy/content_store/base.rb +7 -2
  71. data/lib/phronomy/context/assembly.rb +18 -22
  72. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  73. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
  74. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  75. data/lib/phronomy/engine/fsm_session.rb +16 -1
  76. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  77. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  78. data/lib/phronomy/execution/task_result.rb +7 -2
  79. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  80. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
  81. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
  82. data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
  83. data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
  84. data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
  85. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
  86. data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
  87. data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
  88. data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
  89. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
  90. data/lib/phronomy/generation/generator_verifier.rb +170 -85
  91. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  92. data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
  93. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
  94. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
  95. data/lib/phronomy/llm_adapter/message.rb +4 -0
  96. data/lib/phronomy/llm_adapter/request.rb +17 -2
  97. data/lib/phronomy/llm_adapter/response.rb +11 -4
  98. data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
  99. data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
  100. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  101. data/lib/phronomy/multi_agent/shared_state.rb +1 -0
  102. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  103. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  104. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  105. data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
  106. data/lib/phronomy/output_parser/parse_error.rb +10 -1
  107. data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
  108. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  109. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  110. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  111. data/lib/phronomy/tool/base.rb +25 -23
  112. data/lib/phronomy/tracing/automatic.rb +14 -8
  113. data/lib/phronomy/tracing/base.rb +37 -7
  114. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  115. data/lib/phronomy/tracing/observation.rb +2 -1
  116. data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
  117. data/lib/phronomy/version.rb +1 -1
  118. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  119. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  120. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  121. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  122. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  123. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  124. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  125. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  126. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  127. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  128. data/lib/phronomy.rb +7 -2
  129. data/scripts/api_snapshot.rb +15 -0
  130. data/scripts/check_readme_ruby.rb +24 -29
  131. data/scripts/check_readme_runnable.rb +92 -81
  132. data/scripts/migrate_generator_verifier_result.rb +18 -0
  133. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  134. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  135. data/sig/phronomy/agent/execution_contract.rbs +4 -3
  136. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  137. data/sig/phronomy/agent.rbs +36 -5
  138. data/sig/phronomy/embeddings/base.rbs +6 -0
  139. data/sig/phronomy/execution/execution.rbs +2 -2
  140. data/sig/phronomy/extensions.rbs +8 -2
  141. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  142. data/sig/phronomy/generation/generator_verifier.rbs +70 -0
  143. data/sig/phronomy/integrations.rbs +14 -0
  144. data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
  145. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
  146. data/sig/phronomy/llm_adapter/values.rbs +8 -2
  147. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  148. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  149. data/sig/phronomy/storage/contracts.rbs +2 -2
  150. data/sig/phronomy/tracing/observation.rbs +1 -1
  151. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  152. data/sig/phronomy/workflow.rbs +4 -0
  153. data/sig/phronomy.rbs +6 -0
  154. metadata +103 -5
  155. data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
  156. data/lib/phronomy/generation/low_confidence_error.rb +0 -14
  157. data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
@@ -3,6 +3,17 @@
3
3
  module Phronomy
4
4
  module Agent
5
5
  StreamEvent = Data.define(:type, :payload)
6
+
7
+ # Own Agents acquired by the block, then attempt every release. A returned
8
+ # TaskResult is awaited and unwrapped; a plain value is returned unchanged.
9
+ # @yield [scope] the Runtime-bound owner of newly materialized Agents
10
+ # @yieldparam scope [Phronomy::Agent::Scope]
11
+ # @return [Object] the block value or resolved TaskResult value
12
+ # @raise [ScopeCleanupError] successful work whose Agent release failed
13
+ # @api public
14
+ def self.open_scope(&block)
15
+ Scope.send(:open, &block)
16
+ end
6
17
  end
7
18
  end
8
19
 
@@ -15,3 +26,5 @@ end
15
26
  # Durable Agent Recovery and Agent-incarnation event binding.
16
27
  require_relative "../recovery/recovery_support"
17
28
  require_relative "../recovery/recovery_coordinator"
29
+
30
+ Phronomy::Agent::Base.prepend(Phronomy::Agent::LifetimeApi)
@@ -12,32 +12,40 @@ module Phronomy
12
12
  # - approval notifications share the Agent listener as :approval_required;
13
13
  # - Recovery resolution uses resolve/resolve_async.
14
14
  module AsyncEventApi
15
- def start_reserved_async(reservation:, input:, config: {})
16
- unless reservation.is_a?(ReservedExecution) && reservation.agent_id == agent_id
17
- raise ArgumentError, "reservation must belong to this Agent"
15
+ def start_reserved_async(reservation:, input:, config: {}, output_schema: nil)
16
+ __with_operation(async: true) do
17
+ unless reservation.is_a?(ReservedExecution) && reservation.agent_id == agent_id
18
+ raise ArgumentError, "reservation must belong to this Agent"
19
+ end
20
+ config = __invocation_config(_prepare_output_config(config, nil, output_schema))
21
+ ExactExecution.start(agent: self, execution_id: reservation.execution_id, input: input,
22
+ config: config.merge(phronomy_reservation: reservation.correlation))
18
23
  end
19
- config = __invocation_config(_prepare_invocation_config(config, nil))
20
- ExactExecution.start(agent: self, execution_id: reservation.execution_id, input: input,
21
- config: config.merge(phronomy_reservation: reservation.correlation))
22
24
  end
23
25
 
24
26
  def resume_async(execution_id, config: {})
25
- config = __invocation_config(_prepare_invocation_config(config, nil))
26
- ExactExecution.start(agent: self, execution_id: execution_id, input: nil, config: config, resume_only: true)
27
+ __with_operation(async: true) do
28
+ config = __invocation_config(_prepare_invocation_config(config, nil))
29
+ ExactExecution.start(agent: self, execution_id: execution_id, input: nil, config: config, resume_only: true)
30
+ end
27
31
  end
28
32
 
29
33
  def resume(execution_id, config: {})
30
- _check_event_loop_reentrancy(:resume, :resume_async)
31
- resume_async(execution_id, config: config).wait_result
34
+ __with_operation do
35
+ _check_event_loop_reentrancy(:resume, :resume_async)
36
+ resume_async(execution_id, config: config).wait_result
37
+ end
32
38
  end
33
39
 
34
40
  def cancel_async(execution_id)
35
- __assert_live_agent!
36
- environment = __execution_environment
37
- environment.submit(on_full: :raise) do
38
- requested = persistence.request_cancellation(agent_id: agent_id, execution_id: execution_id)
39
- ExecutionCancellation.signal(execution_id, agent_id, environment: environment) if requested
40
- persistence.observe_execution(agent_id: agent_id, execution_id: execution_id)
41
+ __with_operation(async: true) do
42
+ __assert_live_agent!
43
+ environment = __execution_environment
44
+ environment.submit(on_full: :raise) do
45
+ requested = persistence.request_cancellation(agent_id: agent_id, execution_id: execution_id)
46
+ ExecutionCancellation.signal(execution_id, agent_id, environment: environment) if requested
47
+ persistence.observe_execution(agent_id: agent_id, execution_id: execution_id)
48
+ end
41
49
  end
42
50
  end
43
51
 
@@ -45,98 +53,114 @@ module Phronomy
45
53
  input,
46
54
  config: {},
47
55
  invocation_context: nil,
56
+ output_schema: nil,
48
57
  **removed_options,
49
58
  &removed_block
50
59
  )
51
- _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
52
- config = _prepare_invocation_config(config, invocation_context)
53
- _check_event_loop_reentrancy(:invoke, :invoke_async)
54
- _start_agent_operation(
55
- input,
56
- config: config,
57
- mode: :invoke,
58
- listener: _phronomy_event_listener
59
- ).wait_result
60
+ __with_operation do
61
+ _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
62
+ config = _prepare_output_config(config, invocation_context, output_schema)
63
+ _check_event_loop_reentrancy(:invoke, :invoke_async)
64
+ _start_agent_operation(
65
+ input,
66
+ config: config,
67
+ mode: :invoke,
68
+ listener: _phronomy_event_listener
69
+ ).wait_result
70
+ end
60
71
  end
61
72
 
62
73
  def invoke_async(
63
74
  input,
64
75
  config: {},
65
76
  invocation_context: nil,
77
+ output_schema: nil,
66
78
  **removed_options,
67
79
  &removed_block
68
80
  )
69
- _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
70
- config = _prepare_invocation_config(config, invocation_context)
71
- _start_agent_operation(
72
- input,
73
- config: config,
74
- mode: :invoke,
75
- listener: _phronomy_event_listener
76
- )
81
+ __with_operation(async: true) do
82
+ _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
83
+ config = _prepare_output_config(config, invocation_context, output_schema)
84
+ _start_agent_operation(
85
+ input,
86
+ config: config,
87
+ mode: :invoke,
88
+ listener: _phronomy_event_listener
89
+ )
90
+ end
77
91
  end
78
92
 
79
93
  def stream(
80
94
  input,
81
95
  config: {},
82
96
  invocation_context: nil,
97
+ output_schema: nil,
83
98
  **removed_options,
84
99
  &removed_block
85
100
  )
86
- _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
87
- listener = _required_stream_listener!(:stream)
88
- config = _prepare_invocation_config(config, invocation_context)
89
- _check_event_loop_reentrancy(:stream, :stream_async)
90
- _start_agent_operation(
91
- input,
92
- config: config,
93
- mode: :stream,
94
- listener: listener
95
- ).wait_result
101
+ __with_operation do
102
+ _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
103
+ listener = _required_stream_listener!(:stream)
104
+ config = _prepare_output_config(config, invocation_context, output_schema)
105
+ _check_event_loop_reentrancy(:stream, :stream_async)
106
+ _start_agent_operation(
107
+ input,
108
+ config: config,
109
+ mode: :stream,
110
+ listener: listener
111
+ ).wait_result
112
+ end
96
113
  end
97
114
 
98
115
  def stream_async(
99
116
  input,
100
117
  config: {},
101
118
  invocation_context: nil,
119
+ output_schema: nil,
102
120
  **removed_options,
103
121
  &removed_block
104
122
  )
105
- _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
106
- listener = _required_stream_listener!(:stream_async)
107
- config = _prepare_invocation_config(config, invocation_context)
108
- _start_agent_operation(
109
- input,
110
- config: config,
111
- mode: :stream,
112
- listener: listener
113
- )
123
+ __with_operation(async: true) do
124
+ _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
125
+ listener = _required_stream_listener!(:stream_async)
126
+ config = _prepare_output_config(config, invocation_context, output_schema)
127
+ _start_agent_operation(
128
+ input,
129
+ config: config,
130
+ mode: :stream,
131
+ listener: listener
132
+ )
133
+ end
114
134
  end
115
135
 
116
136
  def approve(execution_id, approval_request_id:, approved: true, config: {})
117
- _check_event_loop_reentrancy(:approve, :approve_async)
118
- approve_async(
119
- execution_id,
120
- approval_request_id: approval_request_id,
121
- approved: approved,
122
- config: config
123
- ).wait_result
137
+ __with_operation do
138
+ _check_event_loop_reentrancy(:approve, :approve_async)
139
+ approve_async(
140
+ execution_id,
141
+ approval_request_id: approval_request_id,
142
+ approved: approved,
143
+ config: config
144
+ ).wait_result
145
+ end
124
146
  end
125
147
 
126
148
  def approve_async(execution_id, approval_request_id:, approved: true, config: {})
127
- _reject_removed_generic_identity_keys!(config)
128
- owner = Phronomy::Agent::ExecutionRegistry.existing_current&.agent_execution_owner(execution_id)
129
- coordinator = if owner&.agent&.equal?(self)
130
- owner.coordinator
131
- else
132
- execution_coordinator_for(config)
149
+ __with_operation(async: true) do
150
+ config = _prepare_invocation_config(config, nil)
151
+ owner = __execution_environment.existing_registry&.agent_execution_owner(execution_id)
152
+ coordinator = if owner&.agent&.equal?(self)
153
+ owner.coordinator
154
+ else
155
+ execution_coordinator_for(config)
156
+ end
157
+ coordinator.resume(
158
+ execution_id,
159
+ approval_request_id: approval_request_id,
160
+ approved: approved,
161
+ config: config
162
+ )
133
163
  end
134
- coordinator.resume(
135
- execution_id,
136
- approval_request_id: approval_request_id,
137
- approved: approved,
138
- config: config
139
- )
140
164
  end
141
165
 
142
166
  def resolve(
@@ -147,15 +171,17 @@ module Phronomy
147
171
  result: Phronomy::Agent::RecoveryRules::MISSING,
148
172
  error: Phronomy::Agent::RecoveryRules::MISSING
149
173
  )
150
- _check_event_loop_reentrancy(:resolve, :resolve_async)
151
- resolve_async(
152
- execution_id,
153
- expected_execution_revision: expected_execution_revision,
154
- subject: subject,
155
- outcome: outcome,
156
- result: result,
157
- error: error
158
- ).wait_result
174
+ __with_operation do
175
+ _check_event_loop_reentrancy(:resolve, :resolve_async)
176
+ resolve_async(
177
+ execution_id,
178
+ expected_execution_revision: expected_execution_revision,
179
+ subject: subject,
180
+ outcome: outcome,
181
+ result: result,
182
+ error: error
183
+ ).wait_result
184
+ end
159
185
  end
160
186
 
161
187
  def resolve_async(
@@ -166,18 +192,25 @@ module Phronomy
166
192
  result: Phronomy::Agent::RecoveryRules::MISSING,
167
193
  error: Phronomy::Agent::RecoveryRules::MISSING
168
194
  )
169
- Phronomy::Agent::RecoveryCoordinator.new(self).resolve(
170
- execution_id,
171
- expected_execution_revision: expected_execution_revision,
172
- subject: subject,
173
- outcome: outcome,
174
- result: result,
175
- error: error
176
- )
195
+ __with_operation(async: true) do
196
+ Phronomy::Agent::RecoveryCoordinator.new(self).resolve(
197
+ execution_id,
198
+ expected_execution_revision: expected_execution_revision,
199
+ subject: subject,
200
+ outcome: outcome,
201
+ result: result,
202
+ error: error
203
+ )
204
+ end
177
205
  end
178
206
 
179
207
  private
180
208
 
209
+ def _prepare_output_config(config, invocation_context, schema)
210
+ config = _prepare_invocation_config(config, invocation_context)
211
+ schema.nil? ? config : config.merge(phronomy_output_contract: OutputContract.new(schema))
212
+ end
213
+
181
214
  def _start_agent_operation(input, config:, mode:, listener:)
182
215
  config = __invocation_config(_snapshot_durable_context(config))
183
216
  @_phronomy_execution_wiring = config.dup.freeze
@@ -101,6 +101,9 @@ module Phronomy
101
101
  end
102
102
  end
103
103
 
104
+ # Maximum logical LLM calls per execution, including the first call.
105
+ # Tool calls and SDK transport retries do not consume extra iterations.
106
+ # The limit is captured at admission; zero admits no LLM call.
104
107
  def max_iterations(val = nil)
105
108
  if val
106
109
  @max_iterations = val
@@ -292,10 +295,14 @@ module Phronomy
292
295
  # @api private
293
296
  def __invocation_config(config) = config
294
297
 
295
- def knowledge_snapshot = persistence.knowledge_snapshot(agent_id: agent_id)
298
+ def knowledge_snapshot
299
+ __with_operation { persistence.knowledge_snapshot(agent_id: agent_id) }
300
+ end
296
301
 
297
302
  # Listener inheritance is an explicit orchestration choice, not a private getter.
298
- def event_listener = _phronomy_event_listener
303
+ def event_listener
304
+ __with_operation(read_only: true) { _phronomy_event_listener }
305
+ end
299
306
 
300
307
  attr_reader :agent_id, :persistence
301
308
 
@@ -371,65 +378,81 @@ module Phronomy
371
378
  end
372
379
 
373
380
  def agent_root
374
- __assert_agent_accessible!
375
- @root
381
+ __with_operation(read_only: true) do
382
+ __assert_agent_accessible!
383
+ @root
384
+ end
376
385
  end
377
386
 
378
387
  def journal_projection
379
- __assert_agent_accessible!
380
- Agent::JournalProjection.new(
381
- agent_root: @root,
382
- records: _journal_records_snapshot
383
- )
388
+ __with_operation(read_only: true) do
389
+ __assert_agent_accessible!
390
+ Agent::JournalProjection.new(
391
+ agent_root: @root,
392
+ records: _journal_records_snapshot
393
+ )
394
+ end
384
395
  end
385
396
 
386
397
  def transcript
387
- journal_projection.transcript_records
398
+ __with_operation(read_only: true) do
399
+ journal_projection.transcript_records
400
+ end
388
401
  end
389
402
 
390
403
  def clear_transcript!
391
- mutate_context!(:transcript_cleared) do |root|
392
- root.with(
393
- agent_revision: root.agent_revision + 1,
394
- context_revision: root.context_revision + 1,
395
- transcript_generation: root.transcript_generation + 1
396
- )
404
+ __with_operation do
405
+ mutate_context!(:transcript_cleared) do |root|
406
+ root.with(
407
+ agent_revision: root.agent_revision + 1,
408
+ context_revision: root.context_revision + 1,
409
+ transcript_generation: root.transcript_generation + 1
410
+ )
411
+ end
397
412
  end
398
413
  end
399
414
 
400
415
  def clear_knowledge!
401
- mutate_context!(:knowledge_cleared) do |root|
402
- root.with(
403
- agent_revision: root.agent_revision + 1,
404
- context_revision: root.context_revision + 1
405
- )
416
+ __with_operation do
417
+ mutate_context!(:knowledge_cleared) do |root|
418
+ root.with(
419
+ agent_revision: root.agent_revision + 1,
420
+ context_revision: root.context_revision + 1
421
+ )
422
+ end
406
423
  end
407
424
  end
408
425
 
409
426
  def add_knowledge(content, metadata: {})
410
- __assert_live_agent!
411
- current = agent_root
412
- update = state_writer.add_knowledge(root: current, content: content, metadata: metadata)
413
- apply_state_update(update)
414
- self
427
+ __with_operation do
428
+ __assert_live_agent!
429
+ current = agent_root
430
+ update = state_writer.add_knowledge(root: current, content: content, metadata: metadata)
431
+ apply_state_update(update)
432
+ self
433
+ end
415
434
  end
416
435
 
417
436
  def reset_context!
418
- mutate_context!(:context_reset) do |root|
419
- root.with(
420
- agent_revision: root.agent_revision + 1,
421
- context_revision: root.context_revision + 1,
422
- transcript_generation: root.transcript_generation + 1
423
- )
437
+ __with_operation do
438
+ mutate_context!(:context_reset) do |root|
439
+ root.with(
440
+ agent_revision: root.agent_revision + 1,
441
+ context_revision: root.context_revision + 1,
442
+ transcript_generation: root.transcript_generation + 1
443
+ )
444
+ end
424
445
  end
425
446
  end
426
447
 
427
448
  def close!
428
- mutate_context!(:agent_closed, context_affecting: false) do |root|
429
- root.with(
430
- agent_revision: root.agent_revision + 1,
431
- lifecycle_status: :closed
432
- )
449
+ __with_operation do
450
+ mutate_context!(:agent_closed, context_affecting: false) do |root|
451
+ root.with(
452
+ agent_revision: root.agent_revision + 1,
453
+ lifecycle_status: :closed
454
+ )
455
+ end
433
456
  end
434
457
  end
435
458
 
@@ -544,7 +567,7 @@ module Phronomy
544
567
 
545
568
  # @api private
546
569
  def __release_runtime_owner!(runtime)
547
- return self if @_phronomy_runtime_owner_state == :purged
570
+ return self if %i[purged unloaded].include?(@_phronomy_runtime_owner_state)
548
571
  return self unless @_phronomy_runtime_owner.equal?(runtime)
549
572
 
550
573
  @_phronomy_runtime_owner_state = :released
@@ -579,7 +602,12 @@ module Phronomy
579
602
  if load_existing
580
603
  root = records = nil
581
604
  @persistence.transaction do |tx|
582
- root = tx.agents.load(@agent_id)
605
+ begin
606
+ root = tx.agents.load(@agent_id)
607
+ rescue Phronomy::Persistence::NotFoundError => error
608
+ raise if error.is_a?(Phronomy::AgentNotFoundError)
609
+ raise Phronomy::AgentNotFoundError.new(agent_id: @agent_id), cause: error
610
+ end
583
611
  records = tx.journals.read(
584
612
  @agent_id,
585
613
  limit: root.journal_position
@@ -656,11 +684,13 @@ module Phronomy
656
684
  public
657
685
 
658
686
  def tool_approval_policy(&block)
659
- __assert_live_agent!
660
- raise ArgumentError, "tool_approval_policy requires a block" unless block
687
+ __with_operation do
688
+ __assert_live_agent!
689
+ raise ArgumentError, "tool_approval_policy requires a block" unless block
661
690
 
662
- _approval_configuration_mutex.synchronize { @tool_approval_policy = block }
663
- self
691
+ _approval_configuration_mutex.synchronize { @tool_approval_policy = block }
692
+ self
693
+ end
664
694
  end
665
695
 
666
696
  private
@@ -671,6 +701,8 @@ module Phronomy
671
701
  true
672
702
  when :purged
673
703
  raise Phronomy::AgentPurgedError, "Agent #{agent_id.inspect} has been purged"
704
+ when :unloaded
705
+ raise Phronomy::AgentUnloadedError.new(agent_id: agent_id)
674
706
  when :purging
675
707
  raise Phronomy::Error, "Agent #{agent_id.inspect} is being purged"
676
708
  when :recovery_required
@@ -698,6 +730,9 @@ module Phronomy
698
730
  def _prepare_invocation_config(config, invocation_context)
699
731
  __assert_live_agent!
700
732
  _reject_removed_generic_identity_keys!(config)
733
+ if config.keys.any? { |key| %w[output_schema response_schema phronomy_output_contract].include?(key.to_s) }
734
+ raise ArgumentError, "Use the output_schema keyword on a new Agent execution"
735
+ end
701
736
  invocation_context ?
702
737
  config.merge(invocation_context: invocation_context) : config
703
738
 
@@ -728,10 +763,12 @@ module Phronomy
728
763
  end
729
764
 
730
765
  def _complete_result_task(task, result)
766
+ task.finish_operation! if task.respond_to?(:finish_operation!)
731
767
  task.complete(result)
732
768
  end
733
769
 
734
770
  def _fail_result_task(task, error)
771
+ task.finish_operation! if task.respond_to?(:finish_operation!)
735
772
  task.fail(error)
736
773
  end
737
774
 
@@ -840,7 +877,8 @@ module Phronomy
840
877
  definitions = Phronomy::Tool::DefinitionSet.build(tools: invocation.tools.values).definitions
841
878
  Phronomy::LLMAdapter::Request.new(
842
879
  model_config: projection.model_config, system: projection.system,
843
- messages: invocation.messages, tools: definitions, message: projection.ask_message
880
+ messages: invocation.messages, tools: definitions, message: projection.ask_message,
881
+ response_schema: projection.response_schema
844
882
  )
845
883
  end
846
884
 
@@ -19,7 +19,13 @@ module Phronomy
19
19
  def _before_llm_input = @before_llm_input
20
20
  end
21
21
 
22
- attr_accessor :before_llm_input
22
+ def before_llm_input
23
+ __with_operation(read_only: true) { @before_llm_input }
24
+ end
25
+
26
+ def before_llm_input=(value)
27
+ __with_operation { @before_llm_input = value }
28
+ end
23
29
 
24
30
  private
25
31
 
@@ -116,6 +116,10 @@ module Phronomy
116
116
  "content" => normalize(content),
117
117
  "tool_calls" => calls
118
118
  }
119
+ metadata = read_optional(message, :metadata) || {}
120
+ raise ArgumentError, "imported assistant metadata must be a Hash" unless metadata.is_a?(Hash)
121
+ Phronomy::Values::Immutable.validate_canonical_json!(metadata, label: "imported assistant metadata")
122
+ payload["metadata"] = Phronomy::Values::Immutable.copy(metadata) unless metadata.empty?
119
123
  model_id = read_optional(message, :model_id)
120
124
  payload["model_id"] = model_id.to_s if model_id
121
125
 
@@ -46,7 +46,8 @@ module Phronomy
46
46
  :rejected,
47
47
  :error
48
48
 
49
- attr_reader :execution_id,
49
+ attr_reader :final_response,
50
+ :execution_id,
50
51
  :agent,
51
52
  :config,
52
53
  :approval_policy,
@@ -73,7 +74,9 @@ module Phronomy
73
74
  @agent = agent
74
75
  @input = input
75
76
  # The caller's Hash must not become a shared mutable Runtime object.
76
- @config = config.dup
77
+ # OutputContract is admission-only; the durable Manifest owns subsequent
78
+ # schema projection. Never forward the parent promise to an Agent Tool.
79
+ @config = config.except(:phronomy_output_contract)
77
80
  resolved_execution_id = execution_id || @config[:execution_id]
78
81
  @execution_id = resolved_execution_id&.to_s&.freeze
79
82
  invocation_context = @config[:invocation_context]
@@ -269,6 +272,7 @@ module Phronomy
269
272
  raise Phronomy::Error, "LLM operation completed without a response" unless response
270
273
 
271
274
  @user_message_sent = true
275
+ @final_response = response
272
276
  @output = response.content
273
277
  @usage = response.usage
274
278
  @pending_tool_calls = []
@@ -34,6 +34,7 @@ module Phronomy
34
34
  def prepare_provider(operation)
35
35
  staged = stage_provider_call(operation)
36
36
  recorded = encode_provider_records(operation, staged)
37
+ IterationBudget.check!(recorded, default: @agent.class.max_iterations)
37
38
  assembler, prepared = prepare_provider_context(operation, recorded)
38
39
  committed, manifest, manifest_ref = commit_provider_preparation(
39
40
  operation, recorded, assembler, prepared
@@ -56,6 +57,9 @@ module Phronomy
56
57
 
57
58
  def stage_provider_call(operation)
58
59
  metadata = operation.execution.metadata.dup
60
+ metadata[IterationBudget::LIMIT_KEY] = IterationBudget.limit_for(
61
+ operation.execution, default: @agent.class.max_iterations
62
+ )
59
63
  metadata.delete(ExecutionMetadata::TOOL_BATCH_METADATA_KEY)
60
64
  metadata.delete(ExecutionMetadata::RECOVERY_METADATA_KEY)
61
65
  metadata[ExecutionMetadata::PENDING_LLM_ID_KEY] = operation.pending_llm_call_id
@@ -118,7 +122,8 @@ module Phronomy
118
122
  def commit_provider_preparation(operation, recorded, assembler, prepared)
119
123
  manifest, manifest_ref = @persistence.transaction do |tx|
120
124
  assert_local_durable_base!(tx, operation.root)
121
- Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents)
125
+ Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
126
+ response_schema_ref: recorded.metadata.dig("output_contract", "schema_ref"))
122
127
  end
123
128
  updated = execution_with_provider_manifest(recorded, manifest_ref)
124
129
  change = ExecutionChange.new(agent: @agent, current: operation.execution,