phronomy 0.22.0 → 0.24.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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +147 -0
  15. data/docs/architecture/persistence.md +186 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +89 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. data/lib/phronomy/multi_agent/handoff.rb +0 -64
@@ -5,8 +5,48 @@ require "securerandom"
5
5
  module Phronomy
6
6
  module Agent
7
7
  class ToolInvocation
8
- AuthorizationOutcome = Struct.new(:decision, :facts, :reason, :error, :cancelled)
9
- ExecutionOutcome = Struct.new(:result, :error, :cancelled)
8
+ AuthorizationOutcome = Data.define(
9
+ :tool_invocation_id, :decision, :facts, :reason, :error, :cancelled
10
+ ) do
11
+ def initialize(
12
+ tool_invocation_id: nil, decision: nil, facts: nil, reason: nil,
13
+ error: nil, cancelled: false
14
+ )
15
+ super(
16
+ tool_invocation_id: tool_invocation_id&.to_s&.freeze,
17
+ decision: decision,
18
+ facts: facts,
19
+ reason: reason,
20
+ error: error,
21
+ cancelled: !!cancelled
22
+ )
23
+ end
24
+ end
25
+
26
+ ExecutionOutcome = Data.define(:tool_invocation_id, :result, :error, :cancelled) do
27
+ def initialize(tool_invocation_id: nil, result: nil, error: nil, cancelled: false)
28
+ super(
29
+ tool_invocation_id: tool_invocation_id&.to_s&.freeze,
30
+ result: result,
31
+ error: error,
32
+ cancelled: !!cancelled
33
+ )
34
+ end
35
+ end
36
+
37
+ # Operation input captured on EventLoop. Framework-owned container/String
38
+ # value fields are immutable snapshots; callables are explicitly classified
39
+ # Application-owned behavior handles. No Phronomy-managed live domain object
40
+ # crosses this worker boundary as command/request data.
41
+ AuthorizationCommand = Data.define(
42
+ :agent_id, :agent_definition_id, :agent_definition_version,
43
+ :execution_id, :tool_name, :tool_schema,
44
+ :tool_invocation_id, :tool_call_id, :arguments,
45
+ :approval_policy, :approval_facts_callable, :approval_requirement,
46
+ :approval_context, :origin, :metadata
47
+ )
48
+
49
+ private_constant :AuthorizationCommand
10
50
 
11
51
  PREFLIGHT_SETTLED_STATES = %i[
12
52
  authorized awaiting_approval rejected failed cancelled completed
@@ -14,7 +54,7 @@ module Phronomy
14
54
  TERMINAL_STATES = %i[completed rejected failed cancelled].freeze
15
55
 
16
56
  attr_reader :id,
17
- :parent_agent_invocation_id,
57
+ :execution_id,
18
58
  :agent,
19
59
  :tool,
20
60
  :tool_name,
@@ -27,7 +67,6 @@ module Phronomy
27
67
  :result,
28
68
  :error,
29
69
  :status,
30
- :session_id,
31
70
  :phase,
32
71
  :config,
33
72
  :approval_policy,
@@ -35,18 +74,25 @@ module Phronomy
35
74
  :origin,
36
75
  :metadata
37
76
 
38
- def self.missing(parent_agent_invocation_id:, agent:, tool_call:, config: {})
77
+ def self.missing(
78
+ execution_id:,
79
+ agent:,
80
+ tool_call:,
81
+ config: {},
82
+ id: SecureRandom.uuid
83
+ )
39
84
  new(
40
- parent_agent_invocation_id: parent_agent_invocation_id,
85
+ execution_id: execution_id,
41
86
  agent: agent,
42
87
  tool: nil,
43
88
  tool_call: tool_call,
44
- config: config
89
+ config: config,
90
+ id: id
45
91
  ).tap { |invocation| invocation.send(:complete_missing_tool!) }
46
92
  end
47
93
 
48
94
  def initialize(
49
- parent_agent_invocation_id:,
95
+ execution_id:,
50
96
  agent:,
51
97
  tool:,
52
98
  tool_call:,
@@ -55,15 +101,19 @@ module Phronomy
55
101
  approval_context: {},
56
102
  id: SecureRandom.uuid
57
103
  )
104
+ if execution_id.nil? || execution_id.to_s.empty?
105
+ raise ArgumentError, "ToolInvocation requires execution_id"
106
+ end
107
+
58
108
  @id = id.to_s
59
- @parent_agent_invocation_id = parent_agent_invocation_id.to_s
109
+ @execution_id = execution_id.to_s.freeze
60
110
  @agent = agent
61
111
  @tool = tool
62
112
  @tool_name = tool_call.name.to_s
63
113
  @tool_call_id = tool_call.respond_to?(:id) ? tool_call.id : nil
64
114
  raw_arguments = tool_call.respond_to?(:arguments) ? (tool_call.arguments || {}) : {}
65
115
  @raw_arguments = immutable_copy(raw_arguments)
66
- @config = config
116
+ @config = config.dup.freeze
67
117
  @approval_policy = approval_policy
68
118
  @approval_context = immutable_copy(approval_context || {})
69
119
  @origin = tool&.respond_to?(:tool_origin) ? tool.tool_origin.to_sym : :local
@@ -78,12 +128,10 @@ module Phronomy
78
128
  @error = nil
79
129
  @approval_consumed = false
80
130
  @status = :created
81
- @session_id = nil
82
131
  @phase = nil
83
132
  end
84
133
 
85
- def set_graph_metadata(thread_id: nil, phase: nil)
86
- @session_id = thread_id if thread_id
134
+ def set_graph_metadata(phase: nil)
87
135
  @phase = phase
88
136
  end
89
137
 
@@ -91,17 +139,24 @@ module Phronomy
91
139
  case event.type
92
140
  when :authorization_completed
93
141
  outcome = event.payload
94
- outcome = AuthorizationOutcome.new(error: outcome) if outcome.is_a?(Exception)
142
+ if outcome.is_a?(Exception)
143
+ outcome = AuthorizationOutcome.new(tool_invocation_id: @id, error: outcome)
144
+ end
145
+ return :consume unless authoritative_tool_outcome?(outcome)
146
+
95
147
  apply_authorization_outcome(outcome)
96
148
  true
97
149
  when :execution_completed
98
150
  outcome = event.payload
99
151
  if outcome.is_a?(Exception)
100
152
  outcome = ExecutionOutcome.new(
153
+ tool_invocation_id: @id,
101
154
  error: outcome,
102
155
  cancelled: outcome.is_a?(Phronomy::CancellationError)
103
156
  )
104
157
  end
158
+ return :consume unless authoritative_tool_outcome?(outcome)
159
+
105
160
  apply_execution_outcome(outcome)
106
161
  true
107
162
  else
@@ -140,11 +195,12 @@ module Phronomy
140
195
  self
141
196
  end
142
197
 
143
- # Starts authorization and reports exactly one AuthorizationOutcome through
144
- # the callback. No Task is created.
145
198
  def start_authorization(runtime: Phronomy::Runtime.instance, &callback)
146
199
  raise ArgumentError, "start_authorization requires a callback" unless callback
147
200
 
201
+ command = authorization_command
202
+ evaluator = self.class
203
+ tool_invocation_id = @id.to_s.freeze
148
204
  pool = runtime.pool(
149
205
  :authorization,
150
206
  size: Phronomy.configuration.authorization_pool_size,
@@ -158,56 +214,79 @@ module Phronomy
158
214
  timeout: timeout,
159
215
  cancellation_token: @config[:cancellation_token],
160
216
  on_full: :raise
161
- ) { evaluate_authorization }
217
+ ) { evaluator.send(:evaluate_authorization_command, command) }
218
+ runtime.event_loop.supervise_agent_operation(@execution_id, operation)
162
219
  operation.on_complete do |outcome, error|
163
- callback.call(error ? authorization_failure_outcome(error) : outcome)
220
+ callback.call(
221
+ error ? evaluator.send(:authorization_failure_result, tool_invocation_id, error) : outcome
222
+ )
164
223
  end
165
224
  self
166
225
  rescue => error
167
- callback.call(authorization_failure_outcome(error))
226
+ callback.call(
227
+ self.class.send(:authorization_failure_result, @id.to_s, error)
228
+ )
168
229
  self
169
230
  end
170
231
 
171
- # Starts Tool execution and reports completion through the callback.
172
- #
173
- # Both core execution paths use Tool#call_async:
174
- #
175
- # - :cooperative returns a Task without consuming an OffloadPool worker.
176
- # Ordinary cooperative Tools settle that Task inline; Agent-backed Tools
177
- # may start child EventLoop/FSM work and settle later.
178
- # - :offloaded returns an OffloadPool PendingOperation for synchronous work
179
- # that must not occupy the EventLoop.
180
- #
181
- # In either case ToolInvocation remains in :running and resumes only from
182
- # the explicit :execution_completed FSM event posted by the session builder.
183
232
  def start_execution(runtime: Phronomy::Runtime.instance, &callback)
184
233
  raise ArgumentError, "start_execution requires a callback" unless callback
185
234
  unless dispatchable?
186
- callback.call(ExecutionOutcome.new(error: Phronomy::ToolError.new(
187
- "ToolInvocation #{@id} is not authorized for dispatch"
188
- )))
235
+ callback.call(ExecutionOutcome.new(
236
+ tool_invocation_id: @id,
237
+ error: Phronomy::ToolError.new(
238
+ "ToolInvocation #{@id} is not authorized for dispatch"
239
+ )
240
+ ))
189
241
  return self
190
242
  end
191
243
 
244
+ trace_handle = nil
192
245
  case @tool.class.execution_mode
193
246
  when :cooperative, :offloaded
247
+ trace_handle = Phronomy::Tracing::Automatic.start(
248
+ "tool.execute",
249
+ input: @arguments || @raw_arguments,
250
+ agent_id: @agent.agent_id,
251
+ execution_id: @execution_id,
252
+ tool_invocation_id: @id,
253
+ tool_call_id: @tool_call_id,
254
+ tool_name: @tool_name,
255
+ **@agent.send(:_build_caller_meta, @config)
256
+ )
194
257
  operation = start_async_tool_operation(runtime)
195
258
  unless operation.respond_to?(:on_complete)
196
259
  raise Phronomy::ToolError,
197
260
  "Tool #{@tool.class.name}#call_async must return a completion handle"
198
261
  end
262
+ runtime.event_loop.supervise_agent_operation(@execution_id, operation)
199
263
 
264
+ evaluator = self.class
265
+ tool_invocation_id = @id.to_s.freeze
200
266
  operation.on_complete do |result, error|
201
- callback.call(execution_outcome(result, error))
267
+ Phronomy::Tracing::Automatic.finish(
268
+ trace_handle,
269
+ output: result,
270
+ error: error
271
+ )
272
+ callback.call(
273
+ evaluator.send(:build_execution_outcome, tool_invocation_id, result, error)
274
+ )
202
275
  end
203
276
  else
204
- callback.call(ExecutionOutcome.new(error: Phronomy::ConfigurationError.new(
205
- "unknown Tool execution_mode: #{@tool.class.execution_mode.inspect}"
206
- )))
277
+ callback.call(ExecutionOutcome.new(
278
+ tool_invocation_id: @id,
279
+ error: Phronomy::ConfigurationError.new(
280
+ "unknown Tool execution_mode: #{@tool.class.execution_mode.inspect}"
281
+ )
282
+ ))
207
283
  end
208
284
  self
209
285
  rescue => error
210
- callback.call(execution_outcome(nil, error))
286
+ Phronomy::Tracing::Automatic.finish(trace_handle, error: error)
287
+ callback.call(
288
+ self.class.send(:build_execution_outcome, @id.to_s, nil, error)
289
+ )
211
290
  self
212
291
  end
213
292
 
@@ -266,55 +345,59 @@ module Phronomy
266
345
 
267
346
  private
268
347
 
269
- # Runtime is framework execution infrastructure, not part of the public
270
- # Tool#call_async protocol.
271
- #
272
- # Tools using Capability::Base's default async implementation are routed
273
- # directly through ToolExecutor so this ToolInvocation can supply its
274
- # owning Runtime internally. Tools that override #call_async (for example
275
- # Agent-backed Tools) receive only the public Tool async keywords.
276
- def start_async_tool_operation(runtime)
277
- if uses_default_call_async?
278
- Phronomy::Agent::ToolExecutor.call_async(
279
- tool: @tool,
280
- args: @arguments,
281
- cancellation_token: @config[:cancellation_token],
282
- config: @config,
283
- runtime: runtime,
284
- on_full: :raise
285
- )
286
- else
287
- @tool.call_async(
288
- @arguments,
289
- cancellation_token: @config[:cancellation_token],
290
- config: @config
291
- )
292
- end
348
+ def authorization_command
349
+ definition = @agent.class.agent_definition
350
+
351
+ AuthorizationCommand.new(
352
+ agent_id: @agent.agent_id.to_s.freeze,
353
+ agent_definition_id: definition.fetch(:id).to_s.freeze,
354
+ agent_definition_version: Integer(definition.fetch(:version)),
355
+ execution_id: @execution_id,
356
+ tool_name: @tool_name.to_s.freeze,
357
+ tool_schema: self.class.send(:immutable_command_copy, tool_schema),
358
+ tool_invocation_id: @id.to_s.freeze,
359
+ tool_call_id: @tool_call_id&.to_s&.freeze,
360
+ arguments: self.class.send(
361
+ :immutable_command_copy,
362
+ @arguments || {}
363
+ ),
364
+ approval_policy: self.class.send(
365
+ :safe_behavior_handle, @approval_policy, "approval_policy"
366
+ ),
367
+ approval_facts_callable: self.class.send(
368
+ :safe_behavior_handle, authorization_facts_callable, "approval_facts"
369
+ ),
370
+ approval_requirement: self.class.send(
371
+ :safe_behavior_handle, authorization_requirement, "requires_approval"
372
+ ),
373
+ approval_context: self.class.send(
374
+ :immutable_command_copy,
375
+ @approval_context
376
+ ),
377
+ origin: @origin,
378
+ metadata: self.class.send(:immutable_command_copy, @metadata)
379
+ )
293
380
  end
294
381
 
295
- def uses_default_call_async?
296
- @tool.method(:call_async).owner ==
297
- Phronomy::Agent::Context::Capability::Base
382
+ def authorization_facts_callable
383
+ return unless @tool&.class&.respond_to?(:approval_facts)
384
+
385
+ @tool.class.approval_facts
298
386
  end
299
387
 
300
- def execution_outcome(result, error)
301
- if error
302
- ExecutionOutcome.new(
303
- error: error,
304
- cancelled: error.is_a?(Phronomy::CancellationError)
305
- )
306
- else
307
- ExecutionOutcome.new(result: result)
308
- end
388
+ def authorization_requirement
389
+ return false unless @tool&.respond_to?(:requires_approval)
390
+
391
+ @tool.requires_approval
309
392
  end
310
393
 
311
- def evaluate_authorization
312
- request = build_request(facts: {}, default_decision: nil)
313
- facts = evaluate_facts
394
+ def self.evaluate_authorization_command(command)
395
+ request = build_authorization_request(command, facts: {}, default_decision: nil)
396
+ facts = evaluate_authorization_facts(command)
314
397
  request = request.with(facts: facts)
315
- default_decision = evaluate_default_decision(request)
398
+ default_decision = evaluate_default_authorization_decision(command, request)
316
399
  request = request.with(default_decision: default_decision)
317
- decision = @approval_policy ? @approval_policy.call(request) : default_decision
400
+ decision = command.approval_policy ? command.approval_policy.call(request) : default_decision
318
401
  decision = decision.to_sym if decision.respond_to?(:to_sym)
319
402
 
320
403
  unless ApprovalEvaluationRequest::VALID_DECISIONS.include?(decision)
@@ -324,25 +407,33 @@ module Phronomy
324
407
  end
325
408
 
326
409
  reason = if decision == :require_approval
327
- (@origin == :mcp) ? "MCP Tool execution requires approval" : "Tool execution requires approval"
410
+ (command.origin == :mcp) ?
411
+ "MCP Tool execution requires approval" : "Tool execution requires approval"
328
412
  end
329
- AuthorizationOutcome.new(decision: decision, facts: facts, reason: reason)
413
+ AuthorizationOutcome.new(
414
+ tool_invocation_id: command.tool_invocation_id,
415
+ decision: decision,
416
+ facts: facts,
417
+ reason: reason
418
+ )
330
419
  end
420
+ private_class_method :evaluate_authorization_command
331
421
 
332
- def evaluate_facts
333
- callable = @tool.class.approval_facts if @tool.class.respond_to?(:approval_facts)
422
+ def self.evaluate_authorization_facts(command)
423
+ callable = command.approval_facts_callable
334
424
  return {} unless callable
335
425
 
336
- value = callable.call(@arguments, @approval_context)
426
+ value = callable.call(command.arguments, command.approval_context)
337
427
  unless value.nil? || value.is_a?(Hash)
338
428
  raise Phronomy::ConfigurationError,
339
429
  "approval_facts must return a Hash or nil (got #{value.class})"
340
430
  end
341
- immutable_copy(value || {})
431
+ immutable_command_copy(value || {})
342
432
  end
433
+ private_class_method :evaluate_authorization_facts
343
434
 
344
- def evaluate_default_decision(request)
345
- requirement = @tool.respond_to?(:requires_approval) ? @tool.requires_approval : false
435
+ def self.evaluate_default_authorization_decision(command, request)
436
+ requirement = command.approval_requirement
346
437
  requirement = requirement.call(request) if requirement.respond_to?(:call)
347
438
  case requirement
348
439
  when true then :require_approval
@@ -352,40 +443,185 @@ module Phronomy
352
443
  "requires_approval callable must return true or false (got #{requirement.inspect})"
353
444
  end
354
445
  end
446
+ private_class_method :evaluate_default_authorization_decision
355
447
 
356
- def build_request(facts:, default_decision:)
448
+ def self.build_authorization_request(command, facts:, default_decision:)
357
449
  ApprovalEvaluationRequest.new(
358
- agent: @agent,
359
- agent_invocation_id: @parent_agent_invocation_id,
360
- tool: @tool,
361
- tool_name: @tool_name,
362
- tool_schema: tool_schema,
363
- tool_invocation_id: @id,
364
- tool_call_id: @tool_call_id,
365
- arguments: @arguments,
450
+ agent_id: command.agent_id,
451
+ agent_definition_id: command.agent_definition_id,
452
+ agent_definition_version: command.agent_definition_version,
453
+ execution_id: command.execution_id,
454
+ tool_name: command.tool_name,
455
+ tool_schema: command.tool_schema,
456
+ tool_invocation_id: command.tool_invocation_id,
457
+ tool_call_id: command.tool_call_id,
458
+ arguments: command.arguments,
366
459
  facts: facts,
367
- invocation_context: @approval_context,
368
- origin: @origin,
369
- metadata: @metadata,
460
+ invocation_context: command.approval_context,
461
+ origin: command.origin,
462
+ metadata: command.metadata,
370
463
  default_decision: default_decision
371
464
  )
372
465
  end
466
+ private_class_method :build_authorization_request
373
467
 
374
- def authorization_failure_outcome(error)
468
+ def self.immutable_command_copy(value)
469
+ if phronomy_managed_live_domain_object?(value)
470
+ raise Phronomy::ConfigurationError,
471
+ "authorization worker snapshot cannot contain Phronomy-managed live " \
472
+ "domain object #{value.class}"
473
+ end
474
+
475
+ case value
476
+ when Hash
477
+ value.each_with_object({}) do |(key, item), result|
478
+ result[immutable_command_copy(key)] = immutable_command_copy(item)
479
+ end.freeze
480
+ when Array
481
+ value.map { |item| immutable_command_copy(item) }.freeze
482
+ when String
483
+ value.dup.freeze
484
+ else
485
+ # Application-defined opaque objects are permitted by ACS-11 and remain
486
+ # Application-owned. A stricter general value-type protocol is deferred.
487
+ value
488
+ end
489
+ end
490
+ private_class_method :immutable_command_copy
491
+
492
+ def self.phronomy_managed_live_domain_object?(value)
493
+ value.is_a?(Phronomy::Agent::Base) ||
494
+ value.is_a?(Phronomy::Agent::AgentRoot) ||
495
+ value.is_a?(Phronomy::Agent::AgentExecution) ||
496
+ value.is_a?(Phronomy::Agent::AgentInvocation) ||
497
+ value.is_a?(Phronomy::Agent::ToolInvocation) ||
498
+ value.is_a?(Phronomy::Agent::JournalProjection) ||
499
+ value.is_a?(Phronomy::Agent::ExecutionCoordinator) ||
500
+ value.is_a?(Phronomy::Agent::Context::Capability::Base) ||
501
+ value.is_a?(Phronomy::Workflow) ||
502
+ value.is_a?(Phronomy::WorkflowRunner) ||
503
+ value.is_a?(Phronomy::WorkflowContext) ||
504
+ value.is_a?(Phronomy::Runtime) ||
505
+ value.is_a?(Phronomy::Task) ||
506
+ value.is_a?(Phronomy::EventLoop) ||
507
+ value.is_a?(Phronomy::FSMSession) ||
508
+ value.is_a?(Phronomy::FSMSession::EventSink) ||
509
+ value.is_a?(Phronomy::Concurrency::CancellationToken) ||
510
+ value.is_a?(Phronomy::Concurrency::OffloadPool)
511
+ end
512
+ private_class_method :phronomy_managed_live_domain_object?
513
+
514
+ def self.safe_behavior_handle(value, name)
515
+ return value if value.nil? || value == true || value == false
516
+ if phronomy_managed_live_domain_object?(value)
517
+ raise Phronomy::ConfigurationError,
518
+ "#{name} must not be a Phronomy-managed live domain object (got #{value.class})"
519
+ end
520
+ value
521
+ end
522
+ private_class_method :safe_behavior_handle
523
+
524
+ # Compatibility helpers for the existing private behavioral specs. These
525
+ # evaluate a frozen operation command just like the worker path; they do not
526
+ # reintroduce worker-side access to live mutable ToolInvocation state.
527
+ def evaluate_authorization
528
+ self.class.send(:evaluate_authorization_command, authorization_command)
529
+ end
530
+
531
+ def evaluate_facts
532
+ self.class.send(:evaluate_authorization_facts, authorization_command)
533
+ end
534
+
535
+ def evaluate_default_decision(request)
536
+ self.class.send(
537
+ :evaluate_default_authorization_decision,
538
+ authorization_command,
539
+ request
540
+ )
541
+ end
542
+
543
+ def build_request(facts:, default_decision:)
544
+ self.class.send(
545
+ :build_authorization_request,
546
+ authorization_command,
547
+ facts: facts,
548
+ default_decision: default_decision
549
+ )
550
+ end
551
+
552
+ def start_async_tool_operation(runtime)
553
+ if uses_default_call_async?
554
+ Phronomy::Agent::ToolExecutor.call_async(
555
+ tool: @tool,
556
+ args: @arguments,
557
+ cancellation_token: @config[:cancellation_token],
558
+ config: @config,
559
+ runtime: runtime,
560
+ on_full: :raise
561
+ )
562
+ else
563
+ tool_config = @tool.class.respond_to?(:__framework_owned_operation?) ?
564
+ @config.merge(phronomy_tool_invocation_id: @id, execution_id: @execution_id).freeze : @config
565
+ @tool.call_async(
566
+ @arguments,
567
+ cancellation_token: @config[:cancellation_token],
568
+ config: tool_config
569
+ )
570
+ end
571
+ end
572
+
573
+ def uses_default_call_async?
574
+ @tool.method(:call_async).owner ==
575
+ Phronomy::Agent::Context::Capability::Base
576
+ end
577
+
578
+ def self.build_execution_outcome(tool_invocation_id, result, error)
579
+ if error
580
+ ExecutionOutcome.new(
581
+ tool_invocation_id: tool_invocation_id,
582
+ error: error,
583
+ cancelled: error.is_a?(Phronomy::CancellationError)
584
+ )
585
+ else
586
+ ExecutionOutcome.new(tool_invocation_id: tool_invocation_id, result: result)
587
+ end
588
+ end
589
+ private_class_method :build_execution_outcome
590
+
591
+ def self.authorization_failure_result(tool_invocation_id, error)
375
592
  if error.is_a?(Phronomy::TimeoutError) ||
376
593
  error.is_a?(Phronomy::TransportError) ||
377
594
  error.is_a?(Phronomy::BackpressureError)
378
595
  AuthorizationOutcome.new(
596
+ tool_invocation_id: tool_invocation_id,
379
597
  decision: :require_approval,
380
598
  facts: {},
381
599
  reason: "Authorization could not be completed safely: #{error.message}"
382
600
  )
383
601
  elsif error.is_a?(Phronomy::CancellationError)
384
- AuthorizationOutcome.new(error: error, cancelled: true)
602
+ AuthorizationOutcome.new(
603
+ tool_invocation_id: tool_invocation_id, error: error, cancelled: true
604
+ )
385
605
  else
386
- AuthorizationOutcome.new(error: error)
606
+ AuthorizationOutcome.new(tool_invocation_id: tool_invocation_id, error: error)
387
607
  end
388
608
  end
609
+ private_class_method :authorization_failure_result
610
+
611
+ # Private compatibility helpers used by existing behavioral specs. Runtime
612
+ # callbacks use only the pure class helpers above and captured semantic IDs.
613
+ def execution_outcome(result, error)
614
+ self.class.send(:build_execution_outcome, @id, result, error)
615
+ end
616
+
617
+ def authorization_failure_outcome(error)
618
+ self.class.send(:authorization_failure_result, @id, error)
619
+ end
620
+
621
+ def authoritative_tool_outcome?(outcome)
622
+ outcome.respond_to?(:tool_invocation_id) &&
623
+ outcome.tool_invocation_id.to_s == @id
624
+ end
389
625
 
390
626
  def apply_authorization_outcome(outcome)
391
627
  unless outcome.is_a?(AuthorizationOutcome)