phronomy 0.31.0 → 0.33.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -0
  3. data/README.md +13 -1
  4. data/RELEASE_CHECKLIST.md +4 -0
  5. data/docs/architecture/structured-output-and-scope.md +5 -4
  6. data/docs/architecture/trust-acceptance-followup.md +15 -0
  7. data/docs/context-policy-budgeting.md +108 -0
  8. data/docs/examples/shared_state_custom_sharing.rb +145 -0
  9. data/docs/features.md +4 -0
  10. data/docs/mcp-client.md +4 -2
  11. data/docs/migrations/shared-state-multi-agent.md +5 -0
  12. data/docs/runtime-and-concurrency.md +195 -0
  13. data/docs/shared-state-sharing.md +223 -0
  14. data/lib/phronomy/agent/approval_wait.rb +8 -0
  15. data/lib/phronomy/agent/async_event_api.rb +60 -0
  16. data/lib/phronomy/agent/base.rb +54 -1
  17. data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
  18. data/lib/phronomy/agent/execution/execution_coordinator.rb +100 -2
  19. data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
  20. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +67 -0
  21. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
  22. data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
  23. data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
  24. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
  25. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
  26. data/lib/phronomy/agent/store.rb +52 -0
  27. data/lib/phronomy/agent/tool_execution/tool_approval_request.rb +3 -2
  28. data/lib/phronomy/agent/tool_execution/tool_invocation.rb +12 -36
  29. data/lib/phronomy/context/context_policy.rb +11 -2
  30. data/lib/phronomy/context/context_policy_input.rb +26 -0
  31. data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
  32. data/lib/phronomy/engine/fsm_session.rb +9 -3
  33. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
  34. data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
  35. data/lib/phronomy/multi_agent/shared_state/binding.rb +44 -0
  36. data/lib/phronomy/multi_agent/shared_state/member_lifecycle_error.rb +32 -0
  37. data/lib/phronomy/multi_agent/shared_state.rb +147 -2
  38. data/lib/phronomy/tool/base.rb +69 -3
  39. data/lib/phronomy/tool/schema.rb +23 -11
  40. data/lib/phronomy/version.rb +1 -1
  41. data/lib/phronomy/workflow/execution/workflow.rb +9 -0
  42. data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
  43. data/scripts/api_snapshot.rb +8 -3
  44. data/sig/phronomy/agent/execution_contract.rbs +13 -0
  45. data/sig/phronomy/agent/store.rbs +1 -0
  46. data/sig/phronomy/agent.rbs +2 -0
  47. data/sig/phronomy/context.rbs +3 -0
  48. data/sig/phronomy/multi_agent.rbs +28 -2
  49. data/sig/phronomy/tool.rbs +1 -0
  50. data/sig/phronomy/workflow.rbs +1 -0
  51. metadata +9 -2
@@ -21,6 +21,36 @@ module Phronomy
21
21
  :approved, :config, :result_task
22
22
  )
23
23
 
24
+ # A resumed approval must never be adopted by the original start handle.
25
+ # Only a hosted decision owns the right to detach its next suspension.
26
+ class ApprovalOperation < OperationResult
27
+ attr_reader :parent_wait
28
+
29
+ def initialize(agent:, name:, parent_wait:)
30
+ super(agent: agent, name: name)
31
+ @parent_wait = parent_wait
32
+ end
33
+ end
34
+
35
+ # The recovery runner uses this task for replay of a framework Tool batch.
36
+ # @api private
37
+ class FrameworkRecoveryTask < Phronomy::TaskResult
38
+ end
39
+
40
+ # Recovery publishes a receipt only after its own coordination commit.
41
+ # The receipt belongs to this incarnation; a new local attempt invalidates it.
42
+ RecoveredCoordinationWait = Data.define(:coordinator, :execution_id, :revision) do
43
+ def current? = coordinator.current_coordination_wait?(self)
44
+ end
45
+ private_constant :ApprovalOperation, :RecoveredCoordinationWait
46
+ attr_reader :recovered_coordination_wait
47
+
48
+ def current_coordination_wait?(wait)
49
+ environment = @agent.__execution_environment
50
+ @recovered_coordination_wait.equal?(wait) && environment.ownership.owned?(@agent) &&
51
+ !environment.registry.agent_execution_admitted?(@agent.agent_id)
52
+ end
53
+
24
54
  # Recovery -> execution-owner commands. These stay on EventLoop and do
25
55
  # not carry persistence readers or arbitrary private method names.
26
56
  RecoverPreparationCommand = Data.define(
@@ -74,6 +104,8 @@ module Phronomy
74
104
  TerminalCommitReady = Data.define(
75
105
  :coordinator, :operation, :delivery, :outcome, :error
76
106
  )
107
+ DetachHostedWait = Data.define(:coordinator, :wait, :task, :parent, :completion)
108
+ private_constant :DetachHostedWait
77
109
  SuspensionCancelledCommand = Data.define(:coordinator, :execution_id)
78
110
 
79
111
  DeferredTerminalCommand = Data.define(
@@ -125,11 +157,11 @@ module Phronomy
125
157
  execution_id,
126
158
  approval_request_id:,
127
159
  approved:,
128
- config: {}
160
+ config: {}, parent_wait: nil
129
161
  )
130
162
  @agent.send(:__assert_live_agent!)
131
163
  @agent.send(:_reject_removed_generic_identity_keys!, config)
132
- result_task = OperationResult.new(agent: @agent,
164
+ result_task = ApprovalOperation.new(agent: @agent, parent_wait: parent_wait,
133
165
  name: "agent-approval-resume:#{execution_id}")
134
166
  command = ResumeCommand.new(
135
167
  coordinator: self,
@@ -148,6 +180,16 @@ module Phronomy
148
180
  result_task
149
181
  end
150
182
 
183
+ # Submit the committed wait for final ownership checks on the EventLoop.
184
+ # @api private
185
+ def request_hosted_detach(wait, task:, parent:)
186
+ completion = Phronomy::TaskResult.deferred(name: "detach-hosted-approval")
187
+ command = DetachHostedWait.new(coordinator: self, wait: wait, task: task, parent: parent, completion: completion)
188
+ accepted = @agent.__execution_environment.registry.post(command, completion: completion)
189
+ raise Phronomy::RuntimeShutdownError, "EventLoop rejected hosted detach" unless accepted
190
+ completion
191
+ end
192
+
151
193
  # Called only from an Agent FSMSession external-operation dispatch state on
152
194
  # EventLoop. Each method captures one operation-specific immutable snapshot
153
195
  # and sends only that snapshot to OffloadPool.
@@ -248,6 +290,8 @@ module Phronomy
248
290
  apply_provider_dispatch_preparation_on_event_loop(command)
249
291
  when ToolDispatchPreparationReady
250
292
  apply_tool_dispatch_preparation_on_event_loop(command)
293
+ when DetachHostedWait
294
+ detach_hosted_wait(command, environment)
251
295
  when ResumeCommand
252
296
  begin_resume_on_event_loop(command)
253
297
  when ResumeCommitReady
@@ -265,6 +309,47 @@ module Phronomy
265
309
 
266
310
  private
267
311
 
312
+ # Called on EventLoop after the host has read the committed wait outside it.
313
+ def detach_hosted_wait(command, environment)
314
+ wait = command.wait
315
+ registry = environment.registry
316
+ state = registry.agent_execution_state(wait.execution_id)
317
+ binding = @suspended_cancellation
318
+ task = command.task || binding&.fetch(2)
319
+ parent = command.parent
320
+ if task.is_a?(ApprovalOperation) && (!command.task.equal?(task) || !task.parent_wait&.current?)
321
+ raise Phronomy::AgentBusyError, "This handle does not own the resumed child wait"
322
+ end
323
+ if parent && registry.agent_execution_admitted?(parent.agent_id)
324
+ raise Phronomy::AgentBusyError, "Hosted parent has another local execution"
325
+ end
326
+ unless state && state.agent.equal?(@agent) && state.execution.status == :suspended &&
327
+ state.execution.execution_revision == wait.execution_revision && state.fsm_session_id.nil? &&
328
+ state.execution.approval_request&.fetch("id", nil) == wait.approval_request_id &&
329
+ binding && task.is_a?(OperationResult) && binding[1].equal?(state.invocation) && binding[2].equal?(task) &&
330
+ !state.invocation.config[:cancellation_token]&.cancelled? && registry.agent_execution_quiescent?(wait.execution_id)
331
+ raise Phronomy::AgentBusyError, "Hosted operation no longer owns this approval wait"
332
+ end
333
+ registry.mark_agent_execution_admission(@agent.agent_id, execution_id: wait.execution_id,
334
+ state: :suspended, expected_state: :suspended)
335
+ task.reserve_approval_detach!(command, parent: parent)
336
+ begin
337
+ # release_agent_execution validates all remaining physical work before mutation.
338
+ registry.release_agent_execution(wait.execution_id)
339
+ rescue
340
+ environment.ownership.finish_unload(@agent, command, success: false, parent: parent)
341
+ raise
342
+ end
343
+ clear_suspended_cancellation
344
+ registry.release_agent_execution_admission(@agent.agent_id, execution_id: wait.execution_id)
345
+ waiters = registry.take_agent_completion_waiters(wait.execution_id, fallback: task)
346
+ environment.ownership.finish_unload(@agent, command, success: true, parent: parent)
347
+ waiters.each { |waiter| @agent.send(:_complete_result_task, waiter, wait) }
348
+ command.completion.complete(wait)
349
+ rescue => error
350
+ command.completion.fail(error)
351
+ end
352
+
268
353
  # ----------------------------------------------------------------------
269
354
  # Initial preparation
270
355
  # ----------------------------------------------------------------------
@@ -296,6 +381,7 @@ module Phronomy
296
381
  # must abort instead of deleting the Agent underneath this start.
297
382
  @agent.send(:__assert_live_agent!)
298
383
 
384
+ @recovered_coordination_wait = nil
299
385
  operation = capture_initial_preparation(request, root)
300
386
  task = environment.submit(on_full: :raise) do
301
387
  @initial_preparation.prepare(operation)
@@ -901,6 +987,12 @@ module Phronomy
901
987
  return
902
988
  end
903
989
 
990
+ if request.result_task.is_a?(ApprovalOperation) && (parent_wait = request.result_task.parent_wait)
991
+ unless parent_wait.current? && !@suspended_cancellation
992
+ return fail_task(request.result_task, Phronomy::AgentBusyError.new("Hosted decision requires a restored child wait"))
993
+ end
994
+ end
995
+
904
996
  approval = state.execution.approval_request || {}
905
997
  current_request_id = approval["id"] || approval[:id]
906
998
  unless current_request_id.to_s == request.approval_request_id
@@ -1088,6 +1180,7 @@ module Phronomy
1088
1180
  environment = @agent.__execution_environment
1089
1181
  registry = environment.registry
1090
1182
  state = recovered_execution_state!(command)
1183
+ @recovered_coordination_wait = nil
1091
1184
  validate_recovered_continuation!(state.execution, command.continuation)
1092
1185
  invocation = command.invocation
1093
1186
  unless invocation && invocation.agent.equal?(@agent) &&
@@ -1495,6 +1588,11 @@ module Phronomy
1495
1588
  delivery = ready.delivery
1496
1589
  outcome = ready.outcome
1497
1590
  release_terminal_ownership(environment, operation.execution_id, state)
1591
+ if delivery.result_task.is_a?(OperationResult)
1592
+ delivery.result_task.coordination_wait_execution_id = operation.execution_id
1593
+ elsif delivery.result_task.is_a?(FrameworkRecoveryTask)
1594
+ @recovered_coordination_wait = RecoveredCoordinationWait.new(self, operation.execution_id, outcome.execution.execution_revision)
1595
+ end
1498
1596
  environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| fail_task(task, outcome.error) }
1499
1597
  end
1500
1598
 
@@ -37,7 +37,12 @@ module Phronomy
37
37
  # clear_transcript! does not remove Knowledge, while knowledge_cleared and
38
38
  # context_reset invalidate earlier Knowledge records by Journal position.
39
39
  def context_records
40
- (transcript_records + active_knowledge_records)
40
+ rejected_batches = rejected_tool_batches
41
+ messages = transcript_records.reject do |record|
42
+ %i[assistant_message tool_message].include?(record.kind) &&
43
+ record.llm_call_id && rejected_batches[record.execution_id] == record.llm_call_id
44
+ end
45
+ (messages + active_knowledge_records)
41
46
  .sort_by { |record| [record.sequence || 0, record.record_id] }
42
47
  .freeze
43
48
  end
@@ -52,6 +57,24 @@ module Phronomy
52
57
 
53
58
  private
54
59
 
60
+ # Rejection ends the execution at its final provider Tool batch. That
61
+ # exchange has no complete set of Tool replies to send to a provider.
62
+ # Identify it from existing journal facts, including pre-upgrade histories.
63
+ def rejected_tool_batches
64
+ rejected = {}
65
+ batches = {}
66
+ records.reverse_each do |record|
67
+ if record.kind == :execution_rejected
68
+ rejected[record.execution_id] = true
69
+ elsif record.kind == :assistant_message && rejected.delete(record.execution_id)
70
+ if Array(record.metadata["tool_call_ids"]).any? && record.llm_call_id
71
+ batches[record.execution_id] = record.llm_call_id
72
+ end
73
+ end
74
+ end
75
+ batches
76
+ end
77
+
55
78
  def active_knowledge_records
56
79
  reset_sequence = records.filter_map do |record|
57
80
  record.sequence if KNOWLEDGE_RESET_KINDS.include?(record.kind)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # An opt-in host handle. wait_result yields the ordinary terminal result or
6
+ # an ApprovalWait when the hosted operation itself is detached.
7
+ class HostedInvocation
8
+ def initialize(agent, execution_id, task, resumed_from: nil, &child_approval_wait)
9
+ @agent, @execution_id, @task = agent, execution_id, task
10
+ @environment = agent.__execution_environment
11
+ @resumed_from = resumed_from
12
+ @child_approval_wait = child_approval_wait
13
+ end
14
+
15
+ def wait_result(timeout: nil) = @task.wait_result(timeout: timeout)
16
+
17
+ # Supports racing normal completion or failure with an approval event.
18
+ def on_complete(&callback)
19
+ @task.on_complete(&callback)
20
+ self
21
+ end
22
+
23
+ def detach!(approval_request_id:, execution_id: @resumed_from&.execution_id || @execution_id)
24
+ if Phronomy::WaitPolicy.blocking_forbidden?
25
+ raise Phronomy::EventLoopReentrancyError, "detach! requires a host thread"
26
+ end
27
+ child = execution_id != @execution_id
28
+ wait = if @resumed_from
29
+ if execution_id == @resumed_from.execution_id && approval_request_id != @resumed_from.approval_request_id
30
+ @child_approval_wait.call(execution_id, approval_request_id)
31
+ end
32
+ elsif child
33
+ matching_child_wait(execution_id, approval_request_id)
34
+ else
35
+ @agent.persistence.standalone_approval_wait(agent_id: @agent.agent_id, execution_id: @execution_id)
36
+ end
37
+ unless wait && wait.approval_request_id == approval_request_id
38
+ raise Phronomy::AgentBusyError, "No matching hosted approval wait"
39
+ end
40
+ owner = @environment.registry.agent_execution_owner(execution_id)
41
+ unless owner && (child ? owner.agent.persistence.equal?(@agent.persistence) : owner.agent.equal?(@agent))
42
+ raise Phronomy::AgentBusyError, "Hosted execution has no matching local owner"
43
+ end
44
+ completion = owner.coordinator.request_hosted_detach(wait,
45
+ task: (child && !@resumed_from) ? nil : @task, parent: child ? @agent : nil)
46
+ completion.wait_result
47
+ end
48
+
49
+ private
50
+
51
+ def matching_child_wait(execution_id, approval_request_id)
52
+ settled = @task.done?
53
+ wait = @child_approval_wait&.call(execution_id, approval_request_id)
54
+ return unless wait
55
+
56
+ # The child's event can precede the parent's committed coordination
57
+ # wait. Physical completion and that exact outcome authorize release.
58
+ @task.join
59
+ unless @task.coordination_wait_execution_id == @execution_id && @task.physical_complete?
60
+ @task.wait_result # Propagate a parent failure other than a committed wait.
61
+ raise Phronomy::AgentBusyError, "Hosted parent did not reach its coordination wait"
62
+ end
63
+ wait if settled || @child_approval_wait.call(execution_id, approval_request_id) == wait
64
+ end
65
+ end
66
+ end
67
+ end
@@ -29,6 +29,36 @@ module Phronomy
29
29
 
30
30
  private
31
31
 
32
+ # The participant proves which durable retentions are finished history;
33
+ # Agent controls admission, idleness and release of the local owners.
34
+ def __unload_with_peer_execution!(execution_id, reserved_by:)
35
+ if Phronomy::WaitPolicy.blocking_forbidden?
36
+ raise Phronomy::EventLoopReentrancyError, "Unloading coordinated Agents requires a host thread"
37
+ end
38
+ return false if @_phronomy_runtime_owner_state == :unloaded
39
+ __assert_live_agent!
40
+ peer = persistence.execution_identity(execution_id)
41
+ unless peer.correlation&.slice(*reserved_by.keys) == reserved_by
42
+ raise Phronomy::AgentBusyError, "Execution does not belong to this coordination owner"
43
+ end
44
+ environment = __execution_environment
45
+ environment.ownership.unload_idle_pair(self, peer.agent_id) do |live_peer|
46
+ ids = [agent_id, peer.agent_id].sort
47
+ if ids.any? { |id| environment.existing_registry&.agent_execution_admitted?(id) }
48
+ raise Phronomy::AgentBusyError, "A coordinated Agent has a local execution"
49
+ end
50
+ persistence.transaction do |records, scope|
51
+ unless scope.outermost?
52
+ raise Phronomy::Persistence::TransactionError, "Releasing local owners requires an outermost transaction"
53
+ end
54
+ ids.each { |id| records.executions.assert_idle!(id) }
55
+ unless yield(peer, live_peer, scope)
56
+ raise Phronomy::AgentBusyError, "No matching completed coordination history"
57
+ end
58
+ end
59
+ end
60
+ end
61
+
32
62
  def __with_operation(async: false, read_only: false)
33
63
  lease = __acquire_operation!(read_only: read_only)
34
64
  followed = false
@@ -20,6 +20,13 @@ module Phronomy
20
20
  ownership&.first&.release_operation(ownership[1], ownership[2])
21
21
  end
22
22
 
23
+ def reserve_approval_detach!(transition, parent: nil)
24
+ @mutex.synchronize do
25
+ raise Phronomy::AgentBusyError, "Hosted operation has ended" unless @registry
26
+ @registry.reserve_approval_detach!(@agent, @token, transition, parent: parent)
27
+ end
28
+ end
29
+
23
30
  def follow(task)
24
31
  if task.respond_to?(:on_physical_complete)
25
32
  task.on_physical_complete { release }
@@ -6,6 +6,9 @@ module Phronomy
6
6
  # Only the domain runner acknowledges the end of its accepted operation.
7
7
  # @api private
8
8
  class OperationResult < Phronomy::Concurrency::PhysicalCompletionTask
9
+ # Published on the EventLoop before the task signals a committed coordination wait.
10
+ attr_accessor :coordination_wait_execution_id
11
+
9
12
  def initialize(agent:, name:)
10
13
  super(name: name)
11
14
  @operation_lease = agent.send(:__acquire_operation!)
@@ -20,6 +23,15 @@ module Phronomy
20
23
  release_if_quiet
21
24
  end
22
25
 
26
+ def reserve_approval_detach!(transition, parent: nil)
27
+ @lifetime_mutex.synchronize do
28
+ if @finished || @released || !@work.zero?
29
+ raise Phronomy::AgentBusyError, "Hosted operation is still busy or has finished"
30
+ end
31
+ @operation_lease.reserve_approval_detach!(transition, parent: parent)
32
+ end
33
+ end
34
+
23
35
  def track(task)
24
36
  @lifetime_mutex.synchronize { @work += 1 }
25
37
  task.on_physical_complete do
@@ -165,12 +165,38 @@ module Phronomy
165
165
  true
166
166
  end
167
167
 
168
+ # Transfer only the single outstanding hosting operation, atomically with
169
+ # the same admission gate used by all other Agent operations.
170
+ def reserve_approval_detach!(agent, operation_token, transition, parent: nil)
171
+ @mutex.synchronize do
172
+ ensure_running!
173
+ entry = @entries[agent.agent_id]
174
+ operations = @operations[agent]
175
+ unless entry&.state == :live && entry.agent.equal?(agent) && !@scope_owners[agent.agent_id] &&
176
+ operations&.size == 1 && operations.key?(operation_token)
177
+ raise Phronomy::AgentBusyError, "Agent has another owner or accepted operation"
178
+ end
179
+ if parent
180
+ entry = @entries[parent.agent_id]
181
+ unless entry&.state == :live && entry.agent.equal?(parent) && !@scope_owners[parent.agent_id]
182
+ raise Phronomy::AgentBusyError, "Hosted parent no longer owns the local Agent"
183
+ end
184
+ assert_no_operations!(parent)
185
+ @entries[parent.agent_id] = Entry.new(state: :unloading, agent: parent, token: transition)
186
+ end
187
+ @entries[agent.agent_id] = Entry.new(state: :unloading, agent: agent, token: transition)
188
+ end
189
+ end
190
+
168
191
  # Returns the already accepted transition for duplicate callers.
169
192
  def reserve_unload(agent)
170
193
  @mutex.synchronize do
171
194
  ensure_running!
172
195
  entry = @entries[agent.agent_id]
173
196
  if entry&.state == :unloading && entry.agent.equal?(agent)
197
+ unless entry.token.is_a?(UnloadTransition)
198
+ raise Phronomy::AgentBusyError, "Agent has a coordinated release in progress"
199
+ end
174
200
  return [entry.token, false]
175
201
  end
176
202
  unless entry&.state == :live && entry.agent.equal?(agent)
@@ -183,22 +209,28 @@ module Phronomy
183
209
  end
184
210
  end
185
211
 
186
- def finish_unload(agent, transition, success:)
187
- @mutex.synchronize do
188
- entry = @entries[agent.agent_id]
189
- unless entry&.state == :unloading && entry.agent.equal?(agent) && entry.token.equal?(transition)
190
- raise Phronomy::RuntimeShutdownError, "Agent unload reservation is no longer authoritative"
191
- end
192
- if success
193
- agent.send(:__mark_unloaded!)
194
- @entries.delete(agent.agent_id)
195
- @scope_owners.delete(agent.agent_id)
196
- else
197
- @entries[agent.agent_id] = Entry.new(state: :live, agent: agent, token: nil)
212
+ def finish_unload(agent, transition, success:, parent: nil)
213
+ owners = {agent.agent_id => agent}
214
+ owners[parent.agent_id] = parent if parent
215
+ finish_unload_entries(owners, transition, success: success)
216
+ end
217
+
218
+ # Reserve the peer identity even when no instance is loaded. Its absence
219
+ # is then stable until the caller's durable checks commit or roll back.
220
+ def unload_idle_pair(parent, peer_id)
221
+ raise Phronomy::AgentBusyError, "Expected distinct Agent identities" if parent.agent_id == peer_id
222
+ transition = Object.new.freeze
223
+ # A host thread may exit during verification. Keep the short two-owner
224
+ # registry changes indivisible even in the presence of an async interrupt.
225
+ Thread.handle_interrupt(Object => :never) do
226
+ owners = reserve_idle_pair!(parent, peer_id, transition)
227
+ begin
228
+ Thread.handle_interrupt(Object => :immediate) { yield owners[peer_id] }
229
+ success = true
230
+ ensure
231
+ finish_unload_entries(owners, transition, success: success)
198
232
  end
199
- @condition.broadcast
200
233
  end
201
- true
202
234
  end
203
235
 
204
236
  def owned?(agent)
@@ -319,6 +351,46 @@ module Phronomy
319
351
 
320
352
  private
321
353
 
354
+ def reserve_idle_pair!(parent, peer_id, transition)
355
+ @mutex.synchronize do
356
+ ensure_running!
357
+ owners = {parent.agent_id => parent, peer_id => @entries[peer_id]&.agent}
358
+ owners.each do |id, agent|
359
+ entry = @entries[id]
360
+ raise Phronomy::AgentBusyError, "Agent belongs to a scope" if @scope_owners[id]
361
+ next if entry.nil? && agent.nil?
362
+ unless entry&.state == :live && entry.agent.equal?(agent)
363
+ raise Phronomy::AgentBusyError, "Local Agent owner changed"
364
+ end
365
+ assert_no_operations!(agent)
366
+ end
367
+ owners.each { |id, agent| @entries[id] = Entry.new(state: :unloading, agent: agent, token: transition) }
368
+ owners
369
+ end
370
+ end
371
+
372
+ def finish_unload_entries(owners, transition, success:)
373
+ @mutex.synchronize do
374
+ owners.each do |id, owner|
375
+ entry = @entries[id]
376
+ unless entry&.state == :unloading && entry.agent.equal?(owner) && entry.token.equal?(transition)
377
+ raise Phronomy::RuntimeShutdownError, "Agent unload reservation is no longer authoritative"
378
+ end
379
+ end
380
+ owners.each do |id, owner|
381
+ if success || owner.nil?
382
+ owner&.send(:__mark_unloaded!)
383
+ @entries.delete(id)
384
+ @scope_owners.delete(id)
385
+ else
386
+ @entries[id] = Entry.new(state: :live, agent: owner, token: nil)
387
+ end
388
+ end
389
+ @condition.broadcast
390
+ end
391
+ true
392
+ end
393
+
322
394
  def reserve_create!(key, scope_owner: nil)
323
395
  token = Object.new.freeze
324
396
  @mutex.synchronize do
@@ -316,7 +316,7 @@ module Phronomy
316
316
  main = agent.send(:execution_coordinator_for, agent.__execution_wiring)
317
317
 
318
318
  if framework_batch?(execution)
319
- internal = Phronomy::TaskResult.deferred(name: "framework-tool-recovery:#{execution.execution_id}")
319
+ internal = ExecutionCoordinator::FrameworkRecoveryTask.deferred(name: "framework-tool-recovery:#{execution.execution_id}")
320
320
  continue_recovery_on_event_loop(execution, internal, material: material)
321
321
  agent.send(:_complete_result_task, completion, agent)
322
322
  return
@@ -108,6 +108,58 @@ module Phronomy
108
108
  end
109
109
  end
110
110
 
111
+ # Read without loading a live Agent. Missing or ineligible waits return nil.
112
+ def standalone_approval_wait(agent_id:, execution_id:, scope: nil)
113
+ approval_wait_for(agent_id: agent_id, execution_id: execution_id, correlation: nil, retentions: [], scope: scope)
114
+ end
115
+
116
+ # Participant observation. An active coordinated wait must match both its
117
+ # exact reservation and every durable retention owned by the participant.
118
+ # @api private
119
+ def approval_wait_for(agent_id:, execution_id:, correlation:, retentions:, scope:)
120
+ with_scope(scope) do |records|
121
+ begin
122
+ execution = records.executions.load(execution_id)
123
+ rescue Phronomy::Persistence::NotFoundError
124
+ next
125
+ end
126
+ assert_execution_owner!(execution, agent_id)
127
+ next unless execution.status == :suspended && (id = execution.approval_request&.fetch("id", nil))
128
+ next unless execution.metadata["reservation"] == correlation && !execution.metadata["execution_extension"]
129
+ next if execution.metadata["cancellation_requested"] || records.cancellations.requested?(agent_id, execution_id)
130
+ next unless records.retentions.list(agent_id) == retentions
131
+
132
+ ApprovalWait.new(execution_id: execution.execution_id.dup.freeze, approval_request_id: id.dup.freeze,
133
+ execution_revision: execution.execution_revision)
134
+ end
135
+ end
136
+
137
+ # A native participant can release a root only at a single replayable
138
+ # Tool dispatch. A mixed batch may still require external Tool evidence.
139
+ # @api private
140
+ def single_dispatch_wait?(agent_id:, execution_id:, invocation_id:, name:, scope:, expected_revision: nil)
141
+ with_scope(scope) do |records|
142
+ execution = records.executions.load(execution_id)
143
+ assert_execution_owner!(execution, agent_id)
144
+ next false if expected_revision && execution.execution_revision != expected_revision
145
+ batch = Array(execution.metadata[ExecutionMetadata::TOOL_BATCH_METADATA_KEY])
146
+ execution.status == :active && execution.phase.to_sym == :dispatching_tools &&
147
+ !execution.metadata["reservation"] && !execution.metadata["cancellation_requested"] &&
148
+ !records.cancellations.requested?(agent_id, execution_id) &&
149
+ batch.size == 1 && batch.first.values_at("tool_invocation_id", "tool_name", "status") == [invocation_id, name, "authorized"]
150
+ end
151
+ end
152
+
153
+ # Check a historical reference without reading its output or error content.
154
+ # @api private
155
+ def terminal_execution?(agent_id:, execution_id:, scope:, reservation: nil)
156
+ with_scope(scope) do |records|
157
+ execution = records.executions.load(execution_id)
158
+ assert_execution_owner!(execution, agent_id)
159
+ execution.terminal? && (reservation.nil? || execution.metadata["reservation"] == reservation)
160
+ end
161
+ end
162
+
111
163
  def active_executions(agent_id:, scope: nil)
112
164
  with_scope(scope) do |records|
113
165
  records.executions.list_active(agent_id).map do |execution|
@@ -101,12 +101,13 @@ module Phronomy
101
101
  end
102
102
 
103
103
  def self.build_item(invocation)
104
+ display = invocation.approval_display
104
105
  Item.new(
105
106
  tool_invocation_id: invocation.id,
106
107
  tool_call_id: invocation.tool_call_id,
107
108
  tool_name: invocation.tool_name,
108
- arguments: invocation.display_arguments,
109
- facts: invocation.display_facts,
109
+ arguments: display.fetch(:arguments),
110
+ facts: display.fetch(:facts),
110
111
  reason: invocation.authorization_reason,
111
112
  origin: invocation.origin,
112
113
  metadata: invocation.metadata
@@ -333,14 +333,21 @@ module Phronomy
333
333
  @tool&.respond_to?(:parameters_schema) ? @tool.parameters_schema : {}
334
334
  end
335
335
 
336
- def display_arguments
337
- redact_for_display(@arguments || @raw_arguments)
338
- end
336
+ # The approval Item snapshots these fields at its own construction boundary.
337
+ # @api private
338
+ def approval_display
339
+ return {arguments: {}.freeze, facts: {}.freeze}.freeze unless @tool&.respond_to?(:approval_display)
339
340
 
340
- def display_facts
341
- redact_value(@facts, sensitive_argument_values)
341
+ display = @tool.approval_display(arguments: @arguments || @raw_arguments, facts: @facts)
342
+ unless Hash === display && Hash === display.fetch(:arguments, nil) && Hash === display.fetch(:facts, nil)
343
+ raise Phronomy::ConfigurationError, "Tool approval_display must return :arguments and :facts Hashes"
344
+ end
345
+ display
342
346
  end
343
347
 
348
+ def display_arguments = immutable_copy(approval_display.fetch(:arguments))
349
+ def display_facts = immutable_copy(approval_display.fetch(:facts))
350
+
344
351
  private
345
352
 
346
353
  def restore_saved_state!(status, result)
@@ -544,37 +551,6 @@ module Phronomy
544
551
  def immutable_copy(value)
545
552
  Phronomy::Values::Immutable.copy(value)
546
553
  end
547
-
548
- def redact_for_display(value)
549
- if @tool&.respond_to?(:redacted_args, true)
550
- immutable_copy(@tool.send(:redacted_args, value || {}))
551
- else
552
- immutable_copy(value || {})
553
- end
554
- end
555
-
556
- def sensitive_argument_values
557
- return [] unless @tool&.class&.respond_to?(:redact_params)
558
- normalized = (@arguments || @raw_arguments || {}).transform_keys(&:to_sym)
559
- @tool.class.redact_params.filter_map { |name| normalized[name] }
560
- end
561
-
562
- def redact_value(value, sensitive_values)
563
- return "[REDACTED]" if sensitive_values.any? { |sensitive| sensitive == value }
564
-
565
- case value
566
- when Hash
567
- value.each_with_object({}) do |(key, item), result|
568
- result[key] = redact_value(item, sensitive_values)
569
- end.freeze
570
- when Array
571
- value.map { |item| redact_value(item, sensitive_values) }.freeze
572
- when String
573
- "[REDACTED]"
574
- else
575
- value
576
- end
577
- end
578
554
  end
579
555
  end
580
556
  end
@@ -11,6 +11,16 @@ module Phronomy
11
11
 
12
12
  protected
13
13
 
14
+ # Uses the same canonical representation and estimator as generated Policy
15
+ # items. Estimates are planning values, not a model tokenizer guarantee.
16
+ # @param content [Object] text or JSON-serializable content
17
+ # @return [Integer] estimated token count
18
+ # @api public
19
+ def estimate_tokens(content)
20
+ bytes = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
21
+ Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
22
+ end
23
+
14
24
  def instruction_item(
15
25
  content:,
16
26
  role: :system,
@@ -113,8 +123,7 @@ module Phronomy
113
123
  end
114
124
 
115
125
  def estimate_content(content)
116
- bytes = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
117
- Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
126
+ estimate_tokens(content)
118
127
  end
119
128
  end
120
129
  end