phronomy 0.31.0 → 0.32.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +7 -1
  4. data/docs/features.md +2 -0
  5. data/docs/runtime-and-concurrency.md +159 -0
  6. data/lib/phronomy/agent/approval_wait.rb +8 -0
  7. data/lib/phronomy/agent/async_event_api.rb +60 -0
  8. data/lib/phronomy/agent/base.rb +1 -1
  9. data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
  10. data/lib/phronomy/agent/execution/execution_coordinator.rb +89 -2
  11. data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
  12. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
  13. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
  14. data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
  15. data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
  16. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
  17. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
  18. data/lib/phronomy/agent/store.rb +52 -0
  19. data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
  20. data/lib/phronomy/engine/fsm_session.rb +9 -3
  21. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
  22. data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
  23. data/lib/phronomy/version.rb +1 -1
  24. data/lib/phronomy/workflow/execution/workflow.rb +9 -0
  25. data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
  26. data/scripts/api_snapshot.rb +2 -0
  27. data/sig/phronomy/agent/execution_contract.rbs +13 -0
  28. data/sig/phronomy/agent/store.rbs +1 -0
  29. data/sig/phronomy/agent.rbs +1 -0
  30. data/sig/phronomy/multi_agent.rbs +2 -0
  31. data/sig/phronomy/workflow.rbs +1 -0
  32. metadata +4 -2
@@ -0,0 +1,70 @@
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
+ completion = Phronomy::TaskResult.deferred(name: "detach-hosted-approval")
41
+ owner = @environment.registry.agent_execution_owner(execution_id)
42
+ unless owner && (child ? owner.agent.persistence.equal?(@agent.persistence) : owner.agent.equal?(@agent))
43
+ raise Phronomy::AgentBusyError, "Hosted execution has no matching local owner"
44
+ end
45
+ command = ExecutionCoordinator::DetachHostedWait.new(coordinator: owner.coordinator, wait: wait,
46
+ task: (child && !@resumed_from) ? nil : @task, parent: child ? @agent : nil, completion: completion)
47
+ accepted = @environment.registry.post(command, completion: completion)
48
+ raise Phronomy::RuntimeShutdownError, "EventLoop rejected hosted detach" unless accepted
49
+ completion.wait_result
50
+ end
51
+
52
+ private
53
+
54
+ def matching_child_wait(execution_id, approval_request_id)
55
+ settled = @task.done?
56
+ wait = @child_approval_wait&.call(execution_id, approval_request_id)
57
+ return unless wait
58
+
59
+ # The child's event can precede the parent's committed coordination
60
+ # wait. Physical completion and that exact outcome authorize release.
61
+ @task.join
62
+ unless @task.coordination_wait_execution_id == @execution_id && @task.physical_complete?
63
+ @task.wait_result # Propagate a parent failure other than a committed wait.
64
+ raise Phronomy::AgentBusyError, "Hosted parent did not reach its coordination wait"
65
+ end
66
+ wait if settled || @child_approval_wait.call(execution_id, approval_request_id) == wait
67
+ end
68
+ end
69
+ end
70
+ 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|
@@ -147,8 +147,9 @@ module Phronomy
147
147
  ordered = Array(items).sort_by { |item| [item.sequence || 0, item.id] }
148
148
  assistants = ordered.select { |item| item.kind == :assistant_message }
149
149
  tool_messages = ordered.select { |item| item.kind == :tool_message }
150
- tool_messages_by_id = tool_messages.group_by(&:tool_call_id)
151
- tool_messages_by_id.each do |tool_call_id, messages|
150
+ tool_messages_by_call = tool_messages.group_by { |item| tool_exchange_key(item, item.tool_call_id) }
151
+ tool_messages_by_call.each_value do |messages|
152
+ tool_call_id = messages.first.tool_call_id
152
153
  if tool_call_id.nil? || tool_call_id.empty?
153
154
  raise ArgumentError, "Tool message Context item requires tool_call_id"
154
155
  end
@@ -160,11 +161,12 @@ module Phronomy
160
161
  assistant_by_tool_call = {}
161
162
  assistants.each do |assistant|
162
163
  assistant.tool_call_ids.each do |tool_call_id|
163
- if assistant_by_tool_call.key?(tool_call_id)
164
+ key = tool_exchange_key(assistant, tool_call_id)
165
+ if assistant_by_tool_call.key?(key)
164
166
  raise ArgumentError,
165
167
  "duplicate assistant Tool Call id in Context Policy input: #{tool_call_id}"
166
168
  end
167
- assistant_by_tool_call[tool_call_id] = assistant
169
+ assistant_by_tool_call[key] = assistant
168
170
  end
169
171
  end
170
172
 
@@ -173,26 +175,25 @@ module Phronomy
173
175
  ordered.each do |item|
174
176
  next if claimed[item.id]
175
177
 
176
- if item.kind == :assistant_message && !item.tool_call_ids.empty?
177
- messages = item.tool_call_ids.map do |tool_call_id|
178
- tool_messages_by_id.fetch(tool_call_id) do
178
+ assistant = if item.kind == :assistant_message && !item.tool_call_ids.empty?
179
+ item
180
+ elsif item.kind == :tool_message
181
+ assistant_by_tool_call.fetch(tool_exchange_key(item, item.tool_call_id)) do
182
+ raise ArgumentError, "orphan Tool message in Context Policy input: #{item.tool_call_id}"
183
+ end
184
+ end
185
+
186
+ group = if assistant
187
+ messages = assistant.tool_call_ids.map do |tool_call_id|
188
+ key = tool_exchange_key(assistant, tool_call_id)
189
+ tool_messages_by_call.fetch(key) do
179
190
  raise ArgumentError,
180
191
  "assistant Tool Call has no Tool message in Context Policy input: #{tool_call_id}"
181
192
  end.first
182
193
  end
183
- group = ([item] + messages).uniq.sort_by { |member| [member.sequence || 0, member.id] }
184
- elsif item.kind == :tool_message
185
- assistant = assistant_by_tool_call[item.tool_call_id]
186
- unless assistant
187
- raise ArgumentError,
188
- "orphan Tool message in Context Policy input: #{item.tool_call_id}"
189
- end
190
- next if claimed[assistant.id]
191
- group = ([assistant] + assistant.tool_call_ids.map do |tool_call_id|
192
- tool_messages_by_id.fetch(tool_call_id).first
193
- end).uniq.sort_by { |member| [member.sequence || 0, member.id] }
194
+ [assistant, *messages].sort_by { |member| [member.sequence || 0, member.id] }
194
195
  else
195
- group = [item]
196
+ [item]
196
197
  end
197
198
 
198
199
  group.each { |member| claimed[member.id] = true }
@@ -202,6 +203,13 @@ module Phronomy
202
203
  mark_framework_required(groups, execution_id: execution_id, call_mode: call_mode)
203
204
  end
204
205
 
206
+ # Providers can number Tool calls afresh for each LLM response. Both native
207
+ # journal records carry the same durable LLM call ID, including after reload.
208
+ def tool_exchange_key(item, tool_call_id)
209
+ origin = item.provenance
210
+ [origin.agent_id, origin.execution_id, origin.llm_call_id, tool_call_id]
211
+ end
212
+
205
213
  def mark_framework_required(groups, execution_id:, call_mode:)
206
214
  return groups.map(&:freeze).freeze unless call_mode.to_sym == :complete
207
215
 
@@ -96,6 +96,7 @@ module Phronomy
96
96
  recursion_limit_scope: :session,
97
97
  resume_event: nil,
98
98
  resume_phase: nil,
99
+ require_resume_transition: false,
99
100
  stable_observer: nil,
100
101
  context_metadata: {},
101
102
  event_sink: nil,
@@ -132,6 +133,7 @@ module Phronomy
132
133
  @event_loop = event_loop
133
134
  @resume_event = resume_event
134
135
  @resume_phase = resume_phase
136
+ @require_resume_transition = require_resume_transition
135
137
  @stable_observer = stable_observer
136
138
  @terminal_policy = terminal_policy
137
139
  @terminal_lifecycle_state = :running
@@ -153,7 +155,8 @@ module Phronomy
153
155
  type: @resume_event,
154
156
  target_id: @id,
155
157
  payload: nil
156
- )
158
+ ),
159
+ require_transition: @require_resume_transition
157
160
  )
158
161
  else
159
162
  @current_state = @entry_point
@@ -242,7 +245,7 @@ module Phronomy
242
245
  end
243
246
  end
244
247
 
245
- def fire_and_advance!(event)
248
+ def fire_and_advance!(event, require_transition: false)
246
249
  automatic = event.type == :state_completed
247
250
  exhausted = if @recursion_limit_scope == :automatic_chain
248
251
  automatic && @automatic_steps >= @recursion_limit
@@ -258,7 +261,10 @@ module Phronomy
258
261
  clear_selected_transition!
259
262
  @tracker.current_event = event if @tracker.respond_to?(:current_event=)
260
263
  transitioned = fire_event!(@tracker, event.type, @current_state)
261
- return unless transitioned
264
+ unless transitioned
265
+ raise ArgumentError, "Event #{event.type.inspect} was rejected at #{@current_state.inspect}" if require_transition
266
+ return
267
+ end
262
268
 
263
269
  @ctx = @tracker.context
264
270
  @context = @ctx
@@ -83,6 +83,91 @@ module Phronomy
83
83
  extension&.state_ref ? persistence.contents.fetch_json(extension.state_ref) : {"children" => [], "cancel_requested" => false}
84
84
  end
85
85
 
86
+ # Validate both ends of the reservation in one durable snapshot. Only
87
+ # one direct, unfinished child belongs to this hosted handoff.
88
+ def child_approval_wait(parent_id, parent_execution_id, execution_id, approval_id, parent_revision: nil, live_child: nil)
89
+ store = persistence.agent_store
90
+ persistence.coordinator.atomic do |scope|
91
+ extension = store.execution_extension(agent_id: parent_id, execution_id: parent_execution_id,
92
+ binding_key: BINDING_KEY, scope: scope)
93
+ next unless extension&.state_ref && extension.binding_version == 1
94
+ snapshot = state_for(extension)
95
+ children = snapshot.fetch("children")
96
+ next unless !snapshot["cancel_requested"] && children.size == 1
97
+ child = children.first
98
+ next unless !child.fetch("imported") && child.fetch("execution_id") == execution_id
99
+ next unless matching_child_class?(child, live_child)
100
+ next unless store.single_dispatch_wait?(agent_id: parent_id, execution_id: parent_execution_id,
101
+ invocation_id: child.fetch("slot"), name: "dispatch_to_#{child.fetch("name")}", expected_revision: parent_revision, scope: scope)
102
+ next unless hosted_retentions?(parent_id, parent_execution_id, scope)
103
+
104
+ child_id = child.fetch("agent_id")
105
+ owner = {"kind" => "subagent", "parent_execution_id" => parent_execution_id,
106
+ "parent_agent_id" => parent_id, "slot" => child.fetch("slot")}
107
+ retention = Phronomy::Agent::Retention.new(agent_id: child_id, execution_id: execution_id,
108
+ owner_key: "subagent:#{parent_execution_id}")
109
+ wait = store.approval_wait_for(agent_id: child_id, execution_id: execution_id,
110
+ correlation: owner, retentions: [retention], scope: scope)
111
+ wait if wait&.approval_request_id == approval_id
112
+ end
113
+ end
114
+
115
+ # A terminal record alone does not show that its result reached the
116
+ # parent. Check the native slot, exact reservation and holds together.
117
+ def completed_child_pair?(parent_id, parent_execution_id, child, live_child:, scope:)
118
+ store = persistence.agent_store
119
+ return false unless store.terminal_execution?(agent_id: parent_id, execution_id: parent_execution_id, scope: scope)
120
+ children = stored_children(parent_id, parent_execution_id, scope)
121
+ return false unless children&.size == 1
122
+ slot = children.first
123
+ return false unless slot.fetch("imported") == true && slot.fetch("agent_id") == child.agent_id &&
124
+ slot.fetch("execution_id") == child.execution_id && matching_child_class?(slot, live_child)
125
+
126
+ owner = {"kind" => "subagent", "parent_agent_id" => parent_id, "parent_execution_id" => parent_execution_id,
127
+ "slot" => slot.fetch("slot")}
128
+ return false unless store.terminal_execution?(agent_id: child.agent_id, execution_id: child.execution_id,
129
+ reservation: owner, scope: scope)
130
+ hold = Phronomy::Agent::Retention.new(agent_id: child.agent_id, execution_id: child.execution_id,
131
+ owner_key: "subagent:#{parent_execution_id}")
132
+ return false unless store.retained_references(agent_id: child.agent_id, scope: scope) == [hold]
133
+
134
+ hosted_retentions?(parent_id, parent_execution_id, scope) do |prior_id|
135
+ prior = stored_children(parent_id, prior_id, scope)
136
+ prior && !prior.empty? && prior.all? { |entry| entry.fetch("imported") == true }
137
+ end
138
+ end
139
+
140
+ private def stored_children(parent_id, execution_id, scope)
141
+ extension = persistence.agent_store.execution_extension(agent_id: parent_id, execution_id: execution_id,
142
+ binding_key: BINDING_KEY, scope: scope)
143
+ state_for(extension).fetch("children") if extension&.state_ref && extension.binding_version == 1
144
+ end
145
+
146
+ private def matching_child_class?(slot, live_child)
147
+ klass = self.class.registered_child_class(@agent_class, slot)
148
+ klass && (!live_child || (live_child.agent_id == slot.fetch("agent_id") && live_child.is_a?(klass) &&
149
+ live_child.class.agent_definition == klass.agent_definition && live_child.persistence.equal?(persistence.agent_store)))
150
+ end
151
+
152
+ # Retaining the history of an earlier completed native dispatch on this
153
+ # same Agent does not give anyone a live interest in the current run.
154
+ private def hosted_retentions?(agent_id, execution_id, scope)
155
+ store = persistence.agent_store
156
+ current = Phronomy::Agent::Retention.new(agent_id: agent_id, execution_id: execution_id, owner_key: "subagent:#{execution_id}")
157
+ retentions = store.retained_references(agent_id: agent_id, scope: scope)
158
+ retentions.include?(current) && (retentions - [current]).all? do |retention|
159
+ retention.execution_id && retention.owner_key == "subagent:#{retention.execution_id}" &&
160
+ store.terminal_execution?(agent_id: agent_id, execution_id: retention.execution_id, scope: scope) &&
161
+ (!block_given? || yield(retention.execution_id))
162
+ end
163
+ end
164
+
165
+ def self.registered_child_class(parent_class, child)
166
+ settings = parent_class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
167
+ klass = settings&.fetch(:agent_class)
168
+ klass if klass && klass.agent_definition.transform_keys(&:to_s) == child.fetch("definition")
169
+ end
170
+
86
171
  def self.start(parent:, tool_invocation_id:, parent_execution_id:, config:)
87
172
  completion = Phronomy::TaskResult.deferred(name: "durable-subagent:#{tool_invocation_id}")
88
173
  preparation = Phronomy::Execution.submit(on_full: :raise) do
@@ -92,11 +177,8 @@ module Phronomy
92
177
  snapshot = store.contents.fetch_json(extension.state_ref)
93
178
  child = snapshot.fetch("children").find { |entry| entry.fetch("slot") == tool_invocation_id }
94
179
  raise Phronomy::ExecutionRehydrationRequiredError, "Missing reserved child slot" unless child
95
- settings = parent.class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
96
- unless settings && settings.fetch(:agent_class).agent_definition.transform_keys(&:to_s) == child.fetch("definition")
97
- raise Phronomy::ConfigurationError, "Registered child definition changed"
98
- end
99
- klass = settings.fetch(:agent_class)
180
+ klass = registered_child_class(parent.class, child)
181
+ raise Phronomy::ConfigurationError, "Registered child definition changed" unless klass
100
182
  id = child.fetch("agent_id")
101
183
  retention = Phronomy::Agent::Retention.new(agent_id: id, execution_id: child.fetch("execution_id"), owner_key: "subagent:#{parent_execution_id}")
102
184
  agent = klass.get(id)
@@ -29,6 +29,16 @@ module Phronomy
29
29
  @participant = @coordination_store && DurableSubagentCoordinator.new(agent_class: self.class, persistence: @coordination_store)
30
30
  end
31
31
 
32
+ # Release the local parent and its directly dispatched child after the
33
+ # child's outcome has been imported. The stored history remains intact.
34
+ # @api public
35
+ def unload_with_completed_child!(parent_execution_id, execution_id:)
36
+ owner = {"kind" => "subagent", "parent_agent_id" => agent_id, "parent_execution_id" => parent_execution_id}
37
+ __unload_with_peer_execution!(execution_id, reserved_by: owner) do |child, live_child, scope|
38
+ @participant&.completed_child_pair?(agent_id, parent_execution_id, child, live_child: live_child, scope: scope)
39
+ end
40
+ end
41
+
32
42
  # Current composition, never serialized in an execution record.
33
43
  def __invocation_config(config)
34
44
  if !self.class.registered_subagents.empty? && !@participant
@@ -38,6 +48,14 @@ module Phronomy
38
48
  phronomy_execution_participant: @participant).compact
39
49
  end
40
50
 
51
+ # Answers the current wait of a recovered direct child. If the same
52
+ # execution suspends again the host can release the pair with this handle.
53
+ # @api public
54
+ def approve_hosted_child(parent_execution_id, execution_id:, approval_request_id:, approved: true, config: {})
55
+ resume_hosted_child_approval(parent_execution_id, execution_id: execution_id,
56
+ approval_request_id: approval_request_id, approved: approved, config: config)
57
+ end
58
+
41
59
  def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
42
60
  # A subagent Tool is logically asynchronous: ToolInvocation starts the
43
61
  # child Agent and resumes when its completion TaskResult settles. It must not
@@ -224,6 +242,8 @@ module Phronomy
224
242
 
225
243
  private
226
244
 
245
+ def hosted_child_approval_reader = @participant&.method(:child_approval_wait)
246
+
227
247
  def prepare_tool_class(tool_class, invocation: nil)
228
248
  prepared = super
229
249
  return prepared unless self.class._subagent_tool_classes.include?(tool_class)
@@ -234,28 +254,20 @@ module Phronomy
234
254
  end&.last
235
255
  inherits_knowledge = registration ? registration.fetch(:inherit_knowledge, true) : true
236
256
 
237
- captured_context = {parent: self}
257
+ context = {parent: self}
238
258
  # Invocation-owned Tools inherit the durable child slot's knowledge,
239
259
  # captured by the existing preparation transaction off EventLoop.
240
- captured_context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
260
+ context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
241
261
  if invocation
242
- captured_context[:config] = invocation.config
243
- captured_context[:invocation_context] = invocation.config[:invocation_context]
244
- end
245
- captured_context.freeze
246
-
247
- effective_name = prepared.new.name
248
- Class.new(prepared) do
249
- tool_name effective_name
250
- define_method(:call) do |args, **kwargs|
251
- self._orchestrator_context = captured_context
252
- super(args, **kwargs)
253
- end
254
- define_method(:call_async) do |args, **kwargs|
255
- self._orchestrator_context = captured_context
256
- super(args, **kwargs)
257
- end
262
+ context[:config] = invocation.config
263
+ context[:invocation_context] = invocation.config[:invocation_context]
258
264
  end
265
+
266
+ # Cached Tool methods can outlive an execution. Only the instance used
267
+ # for this dispatch should hold its parent and invocation configuration.
268
+ tool = prepared.new
269
+ tool._orchestrator_context = context.freeze
270
+ tool
259
271
  end
260
272
 
261
273
  def active_knowledge_snapshot = knowledge_snapshot