phronomy 0.30.1 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +13 -2
  4. data/docs/architecture/agent-tool-child-lifetime.md +141 -0
  5. data/docs/architecture/dx05-agent-scope.md +4 -2
  6. data/docs/architecture/structured-output-and-scope.md +73 -13
  7. data/docs/decisions/044-agent-default-and-one-shot-composition.md +5 -0
  8. data/docs/features.md +2 -0
  9. data/docs/getting-started.md +26 -0
  10. data/docs/migrations/0.31.md +72 -0
  11. data/docs/runtime-and-concurrency.md +159 -0
  12. data/lib/phronomy/agent/approval_wait.rb +8 -0
  13. data/lib/phronomy/agent/async_event_api.rb +77 -8
  14. data/lib/phronomy/agent/base.rb +28 -3
  15. data/lib/phronomy/agent/composition/run_once.rb +11 -10
  16. data/lib/phronomy/agent/context_preparation.rb +1 -0
  17. data/lib/phronomy/agent/execution/dispatch_preparation.rb +1 -1
  18. data/lib/phronomy/agent/execution/exact_execution.rb +9 -3
  19. data/lib/phronomy/agent/execution/execution_coordinator.rb +95 -2
  20. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -6
  21. data/lib/phronomy/agent/execution/initial_preparation.rb +1 -1
  22. data/lib/phronomy/agent/execution/invocation_actions.rb +17 -15
  23. data/lib/phronomy/agent/execution_change.rb +4 -0
  24. data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
  25. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
  26. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
  27. data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
  28. data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
  29. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
  30. data/lib/phronomy/agent/output_contract.rb +47 -5
  31. data/lib/phronomy/agent/persistence/codec.rb +16 -2
  32. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +1 -0
  33. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
  34. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -2
  35. data/lib/phronomy/agent/runtime_input.rb +7 -0
  36. data/lib/phronomy/agent/store.rb +52 -0
  37. data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
  38. data/lib/phronomy/engine/fsm_session.rb +16 -3
  39. data/lib/phronomy/execution/async_operation.rb +16 -0
  40. data/lib/phronomy/execution/concurrency/result_finalization.rb +106 -0
  41. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +3 -3
  42. data/lib/phronomy/generation/generator_verifier.rb +6 -3
  43. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +1 -1
  44. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +3 -1
  45. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
  46. data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
  47. data/lib/phronomy/tools/agent.rb +58 -22
  48. data/lib/phronomy/version.rb +1 -1
  49. data/lib/phronomy/workflow/execution/workflow.rb +9 -0
  50. data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
  51. data/scripts/api_snapshot.rb +2 -0
  52. data/sig/phronomy/agent/execution_contract.rbs +13 -0
  53. data/sig/phronomy/agent/store.rbs +1 -0
  54. data/sig/phronomy/agent.rbs +8 -5
  55. data/sig/phronomy/execution/async_operation.rbs +1 -0
  56. data/sig/phronomy/generation/generator_verifier.rbs +1 -1
  57. data/sig/phronomy/multi_agent.rbs +2 -0
  58. data/sig/phronomy/tools/agent.rbs +14 -0
  59. data/sig/phronomy/workflow.rbs +1 -0
  60. metadata +8 -2
@@ -668,6 +668,165 @@ imply durable Agent continuation reconstruction.
668
668
  `Phronomy.reset_runtime!` exists primarily for test isolation and performs a real
669
669
  Runtime shutdown before resetting configuration.
670
670
 
671
+ ## Hosting a standalone Agent through approval
672
+
673
+ A host can release one suspended Agent and continue using its Ruby process for other Agents. Hold one external claim per mutable Agent while loading, executing, detaching and recording delivery. Persist a new request's reserved execution ID before dispatch. A host uses the existing incarnation-level listener to receive the approval request.
674
+
675
+ For example, worker A starts a fresh reservation with `agent.start_hosted(reservation:, input:)`. If the run finishes directly, `handle.wait_result` returns the ordinary execution result. On an approval notification, call `handle.detach!(approval_request_id: request.id)` outside EventLoop. Success returns an immutable `ApprovalWait` with the execution ID, approval request ID and committed revision. The same handle then returns that wait. The released Agent object cannot be reused. Existing `invoke_async` handles keep their terminal-result contract.
676
+
677
+ An incorrect or stale approval, outstanding physical work, concurrent operation, live scope or durable retention prevents transfer. A busy response leaves the host responsible for the claim. A spent reservation is rejected. After a verified worker exit, a host can query `store.standalone_approval_wait(agent_id:, execution_id:)` without loading the Agent. A missing or ineligible wait returns nil. Store errors and owner mismatches propagate. Only reclaim a delivery if the original worker is known to have stopped and its claim still matches.
678
+
679
+ Worker B loads the Agent with an `on_event:` listener, checks the restored request against the accepted answer and calls `approve` with the execution and approval request IDs. The host owns input deduplication and result acknowledgement. For a direct subagent, use the parent's hosted handle as described below.
680
+
681
+ ## Hosting an Orchestrator through a child approval
682
+
683
+ A hosted Orchestrator can release its local Agent and one direct subagent when
684
+ the subagent is waiting for Tool approval. The parent execution has one reserved
685
+ child slot and its current Tool batch contains only the dispatch for that slot.
686
+ The host holds exclusive access to both Agents throughout the handoff. It
687
+ delivers work to the next worker only after release succeeds.
688
+
689
+ For example, worker A starts a parent with `start_hosted`. Its listener receives
690
+ the child's approval request. A host thread outside EventLoop passes the IDs
691
+ from that request to the parent handle.
692
+
693
+ ```ruby
694
+ wait = handle.detach!(execution_id: request.execution_id, approval_request_id: request.id)
695
+ ```
696
+
697
+ The returned `Agent::ApprovalWait` identifies the child execution, its approval
698
+ request and its saved revision. Keep the parent Agent and execution IDs with the
699
+ accepted request. Both old Agent objects reject later execution operations.
700
+ Worker A can keep unrelated Agent instances and process another request.
701
+ The parent's original result handle retains its
702
+ `ExecutionRehydrationRequiredError` outcome at this boundary.
703
+
704
+ Worker B loads the same parent class with its Agent Store, coordination Store
705
+ and listener. `parent.resume_async(parent_execution_id)` restores the pending
706
+ child and emits the same approval request. Compare its execution and request
707
+ IDs with the accepted request. The parent attempt reports
708
+ `ExecutionRehydrationRequiredError` while the child is waiting. Obtain the
709
+ restored child from its registered class by child Agent ID, call
710
+ `child.approve(child_execution_id, approval_request_id:, approved:)`, then
711
+ `parent.resume(parent_execution_id)` to import the result. A rejection is a
712
+ child outcome. The parent continues according to its own dispatch and Agent
713
+ logic.
714
+
715
+ If the child may need another handoff, the host answers the current approval
716
+ through the restored parent on a host thread outside EventLoop. This returns a
717
+ hosted handle for the child's operation. If the child finishes, the handle
718
+ yields the child result and the parent can import it. If the child requests
719
+ another approval, the host can use the handle to release the same pair.
720
+
721
+ For example, B approves the child's `apply_request` and the same child execution
722
+ then asks to call `publish_request`. B uses the first approval's ID to answer and
723
+ the second approval's ID to detach. The queue below receives the child approval
724
+ events from the listener installed when B loaded the parent. This fixture
725
+ requests a second approval within its test timeout.
726
+
727
+ ```ruby
728
+ require "timeout"
729
+
730
+ answer = parent.approve_hosted_child(parent_execution_id,
731
+ execution_id: first.execution_id, approval_request_id: first.id, approved: true)
732
+ second = Timeout.timeout(12) { approval_events.pop }
733
+ wait = answer.detach!(execution_id: second.execution_id, approval_request_id: second.id)
734
+ ```
735
+
736
+ After release, `answer.wait_result` returns the same child `ApprovalWait`.
737
+ B sends the existing parent and child execution IDs and the **new** approval
738
+ request ID to C. C restores the pair as B did and answers the new request.
739
+ The completed first Tool stays recorded in the child execution. While the hosted
740
+ answer is pending, read the saved execution to report progress. Calling
741
+ `parent.resume` again can advance the parent revision and invalidate the
742
+ handoff. Resume the parent after the child finishes. A host handling
743
+ arbitrary conversations must observe both approval events and handle completion
744
+ (`on_complete`), since the child can finish without requesting another Tool.
745
+
746
+ A later user request can also start a new hosted execution on the same parent
747
+ Agent. Its own child wait can be detached while the earlier terminal
748
+ execution's native slot history is still retained.
749
+
750
+ A wrong approval, another reserved child, overlapping local work, or a pending
751
+ cancellation prevents transfer. On `AgentBusyError` the host keeps ownership.
752
+ The child remains a coordinated execution in storage, including its original
753
+ slot and retention. Once both executions complete in B, these retentions
754
+ continue to guard ordinary `unload!`. This handoff preserves slot history.
755
+ An application that has finished using that history can separately call
756
+ `stores.multi_agent.forget_subagents(agent_id:, execution_id:)` to remove
757
+ that execution's subagent extension and release its holds.
758
+ The host serializes workers and owns request deduplication, claims, crash
759
+ recovery and acknowledgement.
760
+
761
+ ## Releasing a completed Orchestrator and child
762
+
763
+ A host can release the local owners once the parent has imported its direct
764
+ child's outcome and both executions have finished. Phronomy keeps the durable
765
+ reservation, execution results and both Agents' transcripts. A later worker
766
+ can load the parent by its Agent ID and use that conversation for another turn.
767
+
768
+ For example, after answering the last approval, the worker first lets the
769
+ parent import the child's result. It then passes both execution IDs to the
770
+ Orchestrator, on a host thread holding exclusive delivery for the pair. Call
771
+ this outside an application persistence transaction so the ownership decision
772
+ uses committed records.
773
+
774
+ ```ruby
775
+ result = parent.resume(parent_execution_id)
776
+ parent.unload_with_completed_child!(parent_execution_id,
777
+ execution_id: child_execution_id)
778
+ ```
779
+
780
+ The method returns true on release. The parent and any matching resident child
781
+ leave the local runtime registry together. Retrying on the released parent
782
+ object returns false. A parent reloaded on its own can use the saved pair's IDs
783
+ to unload without materializing the child. To display saved results without
784
+ loading either Agent, use the Agent Store.
785
+
786
+ A child may be complete while the parent is still waiting to import its result.
787
+ In that case release is premature. Keep the host claim, finish the parent and
788
+ then release. Active operations, a scoped owner or a conflicting retention can
789
+ also prevent release. The method covers one imported, directly registered child
790
+ of the specified execution. It does not sweep other child instances that the
791
+ application previously left resident.
792
+
793
+ Generic `unload!` and durable deletion continue to honor retention. Use
794
+ `forget_subagents` only when the application intends to remove the stored
795
+ coordination history. Once a host no longer needs an unloaded Agent, it can
796
+ drop its own references to that instance and its operation handles.
797
+
798
+ ## Delivering an event to a saved Workflow wait
799
+
800
+ A worker can restore a particular visit to a declared Workflow wait. The host holds
801
+ one claim for the Workflow instance, selects the matching Workflow definition,
802
+ and identifies the saved revision that should receive the event.
803
+
804
+ For example, a parent waiting for a review stores the child's Agent and execution
805
+ IDs in its context. A worker verifies the completed child result against those IDs.
806
+ While it owns the parent claim, it calls the following method with the revision
807
+ recorded for that wait:
808
+
809
+ ```ruby
810
+ workflow.send_saved_event(workflow_instance_id: parent_id,
811
+ expected_revision: wait_revision, event: :review_ready, input: {answer: answer})
812
+ ```
813
+
814
+ A later visit can have the same phase and fields but a different revision. The
815
+ earlier delivery raises `Persistence::StateConflictError` before its transition
816
+ executes. A transition whose guard rejects the input raises `ArgumentError` and
817
+ leaves the checkpoint available for a corrected event. After an
818
+ acknowledgement is lost, the host checks the durable parent state for evidence
819
+ of this child's result before acknowledging or retrying the message. It must
820
+ retain the original wait revision throughout retries.
821
+
822
+ The host keeps its claim from reading the parent through committing the transition.
823
+ Two workers could otherwise both run application actions before storage detects a
824
+ conflict. The application must also restore the same Workflow definition and
825
+ configuration. A revision identifies a stored state for one durable instance;
826
+ do not delete and reuse its ID while old deliveries might still arrive. The
827
+ revision does not identify deployed code or make an external Tool effect repeatable.
828
+ A pending in-process `task_state` cannot be restored with this operation.
829
+
671
830
  ## Further design records
672
831
 
673
832
  The `docs/decisions/` directory contains the historical and current Architecture
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Immutable reference to one committed standalone approval boundary.
6
+ ApprovalWait = Data.define(:execution_id, :approval_request_id, :execution_revision)
7
+ end
8
+ end
@@ -12,12 +12,33 @@ module Phronomy
12
12
  # - approval notifications share the Agent listener as :approval_required;
13
13
  # - Recovery resolution uses resolve/resolve_async.
14
14
  module AsyncEventApi
15
- def start_reserved_async(reservation:, input:, config: {}, output_schema: nil)
15
+ # Starts a fresh, standalone execution. Only this handle can detach its wait.
16
+ # The caller holds the external exclusive claim for this Agent.
17
+ def start_hosted(reservation:, input:, config: {}, output_schema: nil, schema_delivery: nil)
18
+ __with_operation do
19
+ if Phronomy::WaitPolicy.blocking_forbidden?
20
+ raise Phronomy::EventLoopReentrancyError, "start_hosted requires a host thread"
21
+ end
22
+ unless reservation.is_a?(ReservedExecution) && reservation.agent_id == agent_id && reservation.correlation.nil?
23
+ raise ArgumentError, "expected a standalone reservation for this Agent"
24
+ end
25
+ id = reservation.execution_id
26
+ unless persistence.observe_execution(agent_id: agent_id, execution_id: id).absent?
27
+ raise Phronomy::Persistence::StateConflictError, "Hosted execution #{id} already exists"
28
+ end
29
+ config = _prepare_output_config(config, nil, output_schema, schema_delivery)
30
+ task = _start_agent_operation(input, config: config.merge(phronomy_reserved_execution_id: id),
31
+ mode: :invoke, listener: _phronomy_event_listener)
32
+ build_hosted_invocation(id, task)
33
+ end
34
+ end
35
+
36
+ def start_reserved_async(reservation:, input:, config: {}, output_schema: nil, schema_delivery: nil)
16
37
  __with_operation(async: true) do
17
38
  unless reservation.is_a?(ReservedExecution) && reservation.agent_id == agent_id
18
39
  raise ArgumentError, "reservation must belong to this Agent"
19
40
  end
20
- config = __invocation_config(_prepare_output_config(config, nil, output_schema))
41
+ config = __invocation_config(_prepare_output_config(config, nil, output_schema, schema_delivery))
21
42
  ExactExecution.start(agent: self, execution_id: reservation.execution_id, input: input,
22
43
  config: config.merge(phronomy_reservation: reservation.correlation))
23
44
  end
@@ -54,12 +75,13 @@ module Phronomy
54
75
  config: {},
55
76
  invocation_context: nil,
56
77
  output_schema: nil,
78
+ schema_delivery: nil,
57
79
  **removed_options,
58
80
  &removed_block
59
81
  )
60
82
  __with_operation do
61
83
  _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
62
- config = _prepare_output_config(config, invocation_context, output_schema)
84
+ config = _prepare_output_config(config, invocation_context, output_schema, schema_delivery)
63
85
  _check_event_loop_reentrancy(:invoke, :invoke_async)
64
86
  _start_agent_operation(
65
87
  input,
@@ -75,12 +97,13 @@ module Phronomy
75
97
  config: {},
76
98
  invocation_context: nil,
77
99
  output_schema: nil,
100
+ schema_delivery: nil,
78
101
  **removed_options,
79
102
  &removed_block
80
103
  )
81
104
  __with_operation(async: true) do
82
105
  _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
83
- config = _prepare_output_config(config, invocation_context, output_schema)
106
+ config = _prepare_output_config(config, invocation_context, output_schema, schema_delivery)
84
107
  _start_agent_operation(
85
108
  input,
86
109
  config: config,
@@ -95,13 +118,14 @@ module Phronomy
95
118
  config: {},
96
119
  invocation_context: nil,
97
120
  output_schema: nil,
121
+ schema_delivery: nil,
98
122
  **removed_options,
99
123
  &removed_block
100
124
  )
101
125
  __with_operation do
102
126
  _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
103
127
  listener = _required_stream_listener!(:stream)
104
- config = _prepare_output_config(config, invocation_context, output_schema)
128
+ config = _prepare_output_config(config, invocation_context, output_schema, schema_delivery)
105
129
  _check_event_loop_reentrancy(:stream, :stream_async)
106
130
  _start_agent_operation(
107
131
  input,
@@ -117,13 +141,14 @@ module Phronomy
117
141
  config: {},
118
142
  invocation_context: nil,
119
143
  output_schema: nil,
144
+ schema_delivery: nil,
120
145
  **removed_options,
121
146
  &removed_block
122
147
  )
123
148
  __with_operation(async: true) do
124
149
  _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
125
150
  listener = _required_stream_listener!(:stream_async)
126
- config = _prepare_output_config(config, invocation_context, output_schema)
151
+ config = _prepare_output_config(config, invocation_context, output_schema, schema_delivery)
127
152
  _start_agent_operation(
128
153
  input,
129
154
  config: config,
@@ -206,9 +231,53 @@ module Phronomy
206
231
 
207
232
  private
208
233
 
209
- def _prepare_output_config(config, invocation_context, schema)
234
+ # An optional composition hook validates the durable parent/child relation.
235
+ def hosted_child_approval_reader = nil
236
+
237
+ def build_hosted_invocation(id, task)
238
+ reader = hosted_child_approval_reader
239
+ return HostedInvocation.new(self, id, task) unless reader
240
+
241
+ HostedInvocation.new(self, id, task) do |execution_id, approval_id|
242
+ owner = __execution_environment.registry.agent_execution_owner(execution_id)
243
+ reader.call(agent_id, id, execution_id, approval_id, live_child: owner.agent) if owner
244
+ end
245
+ end
246
+
247
+ def resume_hosted_child_approval(parent_execution_id, execution_id:, approval_request_id:, approved:, config:)
248
+ __with_operation do
249
+ if Phronomy::WaitPolicy.blocking_forbidden?
250
+ raise Phronomy::EventLoopReentrancyError, "approve_hosted_child requires a host thread"
251
+ end
252
+ reader = hosted_child_approval_reader
253
+ parent_wait = execution_coordinator.recovered_coordination_wait
254
+ unless reader && parent_wait && parent_wait.execution_id == parent_execution_id && parent_wait.current?
255
+ raise Phronomy::AgentBusyError, "Parent has no matching recovered coordination wait"
256
+ end
257
+ owner = __execution_environment.registry.agent_execution_owner(execution_id)
258
+ unless owner&.status == :suspended
259
+ raise Phronomy::AgentBusyError, "No matching local child approval wait"
260
+ end
261
+ lookup = lambda do |child_execution_id, approval_id|
262
+ reader.call(agent_id, parent_execution_id, child_execution_id, approval_id,
263
+ parent_revision: parent_wait.revision, live_child: owner.agent)
264
+ end
265
+ wait = lookup.call(execution_id, approval_request_id)
266
+ raise Phronomy::AgentBusyError, "No matching hosted child approval wait" unless wait
267
+ task = owner.coordinator.resume(wait.execution_id, approval_request_id: wait.approval_request_id, approved: approved,
268
+ config: owner.agent._prepare_invocation_config(config, nil), parent_wait: parent_wait)
269
+ HostedInvocation.new(self, parent_execution_id, task, resumed_from: wait, &lookup)
270
+ end
271
+ end
272
+
273
+ def _prepare_output_config(config, invocation_context, schema, schema_delivery)
210
274
  config = _prepare_invocation_config(config, invocation_context)
211
- schema.nil? ? config : config.merge(phronomy_output_contract: OutputContract.new(schema))
275
+ if schema.nil?
276
+ raise ArgumentError, "schema_delivery requires output_schema" unless schema_delivery.nil?
277
+ return config
278
+ end
279
+ contract = OutputContract.new(schema, schema_delivery: schema_delivery.nil? ? :native : schema_delivery)
280
+ config.merge(phronomy_output_contract: contract)
212
281
  end
213
282
 
214
283
  def _start_agent_operation(input, config:, mode:, listener:)
@@ -400,6 +400,31 @@ module Phronomy
400
400
  end
401
401
  end
402
402
 
403
+ # Materializes the current durable transcript as immutable Message values.
404
+ # Application authorization and presentation filtering remain caller-owned.
405
+ # @return [Array<LLMAdapter::Message>]
406
+ # @api public
407
+ def transcript_messages
408
+ _check_event_loop_reentrancy(:transcript_messages, :transcript_messages_async)
409
+ transcript_messages_async.wait_result
410
+ end
411
+
412
+ # Holds Agent ownership until the submitted read physically completes.
413
+ # @return [TaskResult<Array<LLMAdapter::Message>>]
414
+ # @api public
415
+ def transcript_messages_async
416
+ __with_operation(async: true, read_only: true) do
417
+ __execution_environment.submit(on_full: :raise) do
418
+ root, records = persistence.transaction do |tx|
419
+ current = tx.agents.load(agent_id)
420
+ [current, tx.journals.read(agent_id, limit: current.journal_position)]
421
+ end
422
+ RuntimeInput.new(agent: self, persistence: persistence)
423
+ .materialize_transcript(root: root, records: records)
424
+ end
425
+ end
426
+ end
427
+
403
428
  def clear_transcript!
404
429
  __with_operation do
405
430
  mutate_context!(:transcript_cleared) do |root|
@@ -727,11 +752,11 @@ module Phronomy
727
752
  true
728
753
  end
729
754
 
730
- def _prepare_invocation_config(config, invocation_context)
755
+ protected def _prepare_invocation_config(config, invocation_context)
731
756
  __assert_live_agent!
732
757
  _reject_removed_generic_identity_keys!(config)
733
- if config.keys.any? { |key| %w[output_schema response_schema phronomy_output_contract].include?(key.to_s) }
734
- raise ArgumentError, "Use the output_schema keyword on a new Agent execution"
758
+ if config.keys.any? { |key| %w[output_schema schema_delivery response_schema phronomy_output_contract].include?(key.to_s) }
759
+ raise ArgumentError, "Use the output_schema and schema_delivery keywords on a new Agent execution"
735
760
  end
736
761
  invocation_context ?
737
762
  config.merge(invocation_context: invocation_context) : config
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This convenience API composes a fresh ephemeral Persistence with an Agent.
3
+ # This convenience API owns a fresh ephemeral Persistence and a scoped Agent.
4
4
  # The application entry loads the method definition; Agent execution does not
5
5
  # delegate to or require this higher-level composition implementation.
6
6
  module Phronomy
@@ -18,15 +18,16 @@ module Phronomy
18
18
  raise ArgumentError, "Provide either on_event: or a block, not both"
19
19
  end
20
20
 
21
- persistence = Phronomy::PersistenceComposition.agent
22
- agent = definition.create(
23
- context: context,
24
- knowledge: knowledge,
25
- persistence: persistence,
26
- on_event: on_event,
27
- &event_block
28
- )
29
- agent.invoke(input, **invoke_options)
21
+ open_scope do |scope|
22
+ persistence = Phronomy::PersistenceComposition.agent
23
+ agent = scope.create(definition,
24
+ context: context,
25
+ knowledge: knowledge,
26
+ persistence: persistence,
27
+ on_event: on_event,
28
+ &event_block)
29
+ agent.invoke(input, **invoke_options)
30
+ end
30
31
  end
31
32
  end
32
33
  end
@@ -251,6 +251,7 @@ module Phronomy
251
251
  tools:,
252
252
  current_input:
253
253
  )
254
+ OutputContract.validate_model_config!(model_config) if execution.metadata.key?("output_contract")
254
255
  token_budget = Phronomy::Agent::Settings.current.llm_adapter.input_budget(model_config)
255
256
  policy_input = @input_builder.build(
256
257
  agent_id: agent_root.agent_id,
@@ -123,7 +123,7 @@ module Phronomy
123
123
  manifest, manifest_ref = @persistence.transaction do |tx|
124
124
  assert_local_durable_base!(tx, operation.root)
125
125
  Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
126
- response_schema_ref: recorded.metadata.dig("output_contract", "schema_ref"))
126
+ response_schema_ref: OutputContract.response_schema_ref(recorded.metadata))
127
127
  end
128
128
  updated = execution_with_provider_manifest(recorded, manifest_ref)
129
129
  change = ExecutionChange.new(agent: @agent, current: operation.execution,
@@ -122,9 +122,11 @@ module Phronomy
122
122
  raise Phronomy::Persistence::StateConflictError, "Reserved execution #{@id} correlation mismatch"
123
123
  end
124
124
  unless @resume_only
125
- expected_digest = @config[:phronomy_output_contract]&.digest
126
- if expected_digest != execution.metadata.dig("output_contract", "schema_digest")
127
- raise Phronomy::Persistence::StateConflictError, "Reserved execution output schema mismatch"
125
+ expected_contract = @config[:phronomy_output_contract]
126
+ saved_contract = OutputContract.restore(execution, @agent.persistence.contents)
127
+ if expected_contract&.digest != saved_contract&.digest ||
128
+ expected_contract&.schema_delivery != saved_contract&.schema_delivery
129
+ raise Phronomy::Persistence::StateConflictError, "Reserved execution output contract mismatch"
128
130
  end
129
131
  end
130
132
  ref = execution.metadata["current_input_ref"]
@@ -154,6 +156,10 @@ module Phronomy
154
156
  end
155
157
 
156
158
  def reconcile(failure)
159
+ # A suspended exact observer may already have released its operation.
160
+ # A logically cancelled observer still needs physical acknowledgement.
161
+ return if @completion.physical_complete?
162
+
157
163
  task = @environment.submit(on_full: :raise) do
158
164
  execution = read_execution
159
165
  if execution&.terminal?
@@ -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,7 @@ 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)
77
108
  SuspensionCancelledCommand = Data.define(:coordinator, :execution_id)
78
109
 
79
110
  DeferredTerminalCommand = Data.define(
@@ -125,11 +156,11 @@ module Phronomy
125
156
  execution_id,
126
157
  approval_request_id:,
127
158
  approved:,
128
- config: {}
159
+ config: {}, parent_wait: nil
129
160
  )
130
161
  @agent.send(:__assert_live_agent!)
131
162
  @agent.send(:_reject_removed_generic_identity_keys!, config)
132
- result_task = OperationResult.new(agent: @agent,
163
+ result_task = ApprovalOperation.new(agent: @agent, parent_wait: parent_wait,
133
164
  name: "agent-approval-resume:#{execution_id}")
134
165
  command = ResumeCommand.new(
135
166
  coordinator: self,
@@ -248,6 +279,8 @@ module Phronomy
248
279
  apply_provider_dispatch_preparation_on_event_loop(command)
249
280
  when ToolDispatchPreparationReady
250
281
  apply_tool_dispatch_preparation_on_event_loop(command)
282
+ when DetachHostedWait
283
+ detach_hosted_wait(command, environment)
251
284
  when ResumeCommand
252
285
  begin_resume_on_event_loop(command)
253
286
  when ResumeCommitReady
@@ -265,6 +298,47 @@ module Phronomy
265
298
 
266
299
  private
267
300
 
301
+ # Called on EventLoop after the host has read the committed wait outside it.
302
+ def detach_hosted_wait(command, environment)
303
+ wait = command.wait
304
+ registry = environment.registry
305
+ state = registry.agent_execution_state(wait.execution_id)
306
+ binding = @suspended_cancellation
307
+ task = command.task || binding&.fetch(2)
308
+ parent = command.parent
309
+ if task.is_a?(ApprovalOperation) && (!command.task.equal?(task) || !task.parent_wait&.current?)
310
+ raise Phronomy::AgentBusyError, "This handle does not own the resumed child wait"
311
+ end
312
+ if parent && registry.agent_execution_admitted?(parent.agent_id)
313
+ raise Phronomy::AgentBusyError, "Hosted parent has another local execution"
314
+ end
315
+ unless state && state.agent.equal?(@agent) && state.execution.status == :suspended &&
316
+ state.execution.execution_revision == wait.execution_revision && state.fsm_session_id.nil? &&
317
+ state.execution.approval_request&.fetch("id", nil) == wait.approval_request_id &&
318
+ binding && task.is_a?(OperationResult) && binding[1].equal?(state.invocation) && binding[2].equal?(task) &&
319
+ !state.invocation.config[:cancellation_token]&.cancelled? && registry.agent_execution_quiescent?(wait.execution_id)
320
+ raise Phronomy::AgentBusyError, "Hosted operation no longer owns this approval wait"
321
+ end
322
+ registry.mark_agent_execution_admission(@agent.agent_id, execution_id: wait.execution_id,
323
+ state: :suspended, expected_state: :suspended)
324
+ task.reserve_approval_detach!(command, parent: parent)
325
+ begin
326
+ # release_agent_execution validates all remaining physical work before mutation.
327
+ registry.release_agent_execution(wait.execution_id)
328
+ rescue
329
+ environment.ownership.finish_unload(@agent, command, success: false, parent: parent)
330
+ raise
331
+ end
332
+ clear_suspended_cancellation
333
+ registry.release_agent_execution_admission(@agent.agent_id, execution_id: wait.execution_id)
334
+ waiters = registry.take_agent_completion_waiters(wait.execution_id, fallback: task)
335
+ environment.ownership.finish_unload(@agent, command, success: true, parent: parent)
336
+ waiters.each { |waiter| @agent.send(:_complete_result_task, waiter, wait) }
337
+ command.completion.complete(wait)
338
+ rescue => error
339
+ command.completion.fail(error)
340
+ end
341
+
268
342
  # ----------------------------------------------------------------------
269
343
  # Initial preparation
270
344
  # ----------------------------------------------------------------------
@@ -296,6 +370,7 @@ module Phronomy
296
370
  # must abort instead of deleting the Agent underneath this start.
297
371
  @agent.send(:__assert_live_agent!)
298
372
 
373
+ @recovered_coordination_wait = nil
299
374
  operation = capture_initial_preparation(request, root)
300
375
  task = environment.submit(on_full: :raise) do
301
376
  @initial_preparation.prepare(operation)
@@ -901,6 +976,12 @@ module Phronomy
901
976
  return
902
977
  end
903
978
 
979
+ if request.result_task.is_a?(ApprovalOperation) && (parent_wait = request.result_task.parent_wait)
980
+ unless parent_wait.current? && !@suspended_cancellation
981
+ return fail_task(request.result_task, Phronomy::AgentBusyError.new("Hosted decision requires a restored child wait"))
982
+ end
983
+ end
984
+
904
985
  approval = state.execution.approval_request || {}
905
986
  current_request_id = approval["id"] || approval[:id]
906
987
  unless current_request_id.to_s == request.approval_request_id
@@ -1088,6 +1169,7 @@ module Phronomy
1088
1169
  environment = @agent.__execution_environment
1089
1170
  registry = environment.registry
1090
1171
  state = recovered_execution_state!(command)
1172
+ @recovered_coordination_wait = nil
1091
1173
  validate_recovered_continuation!(state.execution, command.continuation)
1092
1174
  invocation = command.invocation
1093
1175
  unless invocation && invocation.agent.equal?(@agent) &&
@@ -1495,6 +1577,11 @@ module Phronomy
1495
1577
  delivery = ready.delivery
1496
1578
  outcome = ready.outcome
1497
1579
  release_terminal_ownership(environment, operation.execution_id, state)
1580
+ if delivery.result_task.is_a?(OperationResult)
1581
+ delivery.result_task.coordination_wait_execution_id = operation.execution_id
1582
+ elsif delivery.result_task.is_a?(FrameworkRecoveryTask)
1583
+ @recovered_coordination_wait = RecoveredCoordinationWait.new(self, operation.execution_id, outcome.execution.execution_revision)
1584
+ end
1498
1585
  environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| fail_task(task, outcome.error) }
1499
1586
  end
1500
1587
 
@@ -1703,6 +1790,12 @@ module Phronomy
1703
1790
  execution_id,
1704
1791
  fallback: fallback_task
1705
1792
  )
1793
+ # All these waiters observe the same terminal execution. A synchronous
1794
+ # completion callback may request unload immediately, so acknowledge
1795
+ # every finished operation before publishing any logical result. Each
1796
+ # OperationResult still retains its lease until tracked physical work
1797
+ # ends; unrelated operations remain protected by their own leases.
1798
+ waiters.each { |task| task.finish_operation! if task.respond_to?(:finish_operation!) }
1706
1799
  waiters.each do |task|
1707
1800
  settle_after_terminal(
1708
1801
  task, callback_error, event_type, result, execution_error
@@ -286,12 +286,7 @@ module Phronomy
286
286
  agent: @agent,
287
287
  persistence: persistence
288
288
  )
289
- materializer.materialize_journal_records(
290
- JournalProjection.new(
291
- agent_root: root,
292
- records: journal_records
293
- ).transcript_records
294
- )
289
+ materializer.materialize_transcript(root: root, records: journal_records)
295
290
  end
296
291
 
297
292
  def result_base(execution, root)
@@ -192,7 +192,7 @@ module Phronomy
192
192
  @persistence.transaction do |tx|
193
193
  assert_local_durable_base!(tx, root)
194
194
  manifest, manifest_ref = Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
195
- response_schema_ref: staged.metadata.dig("output_contract", "schema_ref"))
195
+ response_schema_ref: OutputContract.response_schema_ref(staged.metadata))
196
196
  active = execution_with_initial_manifest(staged, manifest_ref)
197
197
  tx.executions.save(
198
198
  staged.execution_id,