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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7eb1f72acaa35f02d5f848dac344ea971e6ef190544d2b200fe367409e48e92
4
- data.tar.gz: 26b2de048b30845bdbf973a5e26a6d5fa4b734295d31b81cec2530e9dd896dc8
3
+ metadata.gz: 8eeef6dddca310da11fcdcaf7ffc2490191151cfa070384be8d04f84366efb08
4
+ data.tar.gz: 2b43c66ff8c5484360b2a953bcdd4aa754ef99c3b7bc944285993d866ee34007
5
5
  SHA512:
6
- metadata.gz: eaf92481864d37b545ccf7507541fbd64777eb6ff6369e2f8a674f31341bc2ec439883dfa2964d410c1becc2adc92c2794c9a7a2e6a7a2e5050a78e2ae4fa181
7
- data.tar.gz: 6ca6f91cc3bf270a2694cb5a8a194cc1f4fe8270440ada751a4082f0ae27489c0798743a351d8850e1fbf4e81eb0aac207bee563995e8aa821d2008b2b6916e2
6
+ metadata.gz: 9ebee2313bc7d60b27c6e42e0909fbb1102cca3c220f25575d1c755b18f3d1c00b7ea0dfec36646155a7aff325ba47c99af202c9b7fbf226d45c4fbb07b8bff9
7
+ data.tar.gz: 3b089fdc8447a0a56c66d10d64c809eb26ddc31be6800a55c095409e87e649047afbc42fa1872eab8fd68b7939bd6845edc194b5ec4ab03beca602408f320596
data/CHANGELOG.md CHANGED
@@ -12,6 +12,70 @@ Release history for 0.14.0 and earlier is archived in
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.32.0] - 2026-11-03
16
+
17
+ ### Added
18
+
19
+ - Add `Agent#start_hosted`, `Agent::HostedInvocation` and immutable
20
+ `Agent::ApprovalWait` for safely detaching an exclusively owned standalone Agent
21
+ at its first committed Tool approval wait. A new incarnation can use the
22
+ existing `load` and `approve` operations to continue the same execution.
23
+ - Add `Agent::Store#standalone_approval_wait` to query the committed approval fact without
24
+ transferring a live worker claim. Delivery deduplication, exclusive claims and
25
+ verified worker termination remain application responsibilities. Standalone
26
+ detachment does not transfer parent Workflows or shared children; supported
27
+ direct-child handoff and saved Workflow events are separate APIs below.
28
+
29
+ - Add `Workflow#send_saved_event` to restore a declared wait by Workflow instance
30
+ ID and expected revision before applying an event. The host can reject a
31
+ delayed delivery even when the same Workflow later returns to an identical wait.
32
+ - Allow an Orchestrator's hosted handle to detach its local parent and direct
33
+ child together at a saved child Tool approval. The host supplies the child's
34
+ execution and approval request IDs and retains the parent's execution ID for
35
+ resumption on another worker. Both workers can remain alive.
36
+ - Add `MultiAgent::Orchestrator#approve_hosted_child` to answer a restored
37
+ direct child's approval with a hosted handle. If the same execution asks for
38
+ another approval, the worker can release the pair at the new wait. A third
39
+ worker can restore that wait without replaying the earlier Tool call.
40
+ - Add `MultiAgent::Orchestrator#unload_with_completed_child!` for a worker
41
+ that has finished a direct child dispatch. It can release the local owners
42
+ while keeping the conversation, execution results and native slot history.
43
+
44
+ ### Fixed
45
+
46
+ - Bind native subagent dispatch context to each prepared Tool instance so that
47
+ cached Tool methods do not keep completed Orchestrators alive after unload
48
+ (Issue #440). Saved SQLite execution and conversation history is preserved.
49
+ - Keep distinct native Tool call/result pairs when a provider reuses its call ID
50
+ in later responses on the same Agent. A subsequent request can use the saved
51
+ conversation without executing either Tool again to rebuild context.
52
+ - Restore mutable Workflow context fields from durable checkpoints when
53
+ delivering a saved event or starting another invocation of the same instance.
54
+ Actions can update nested strings, arrays and hashes without changing the
55
+ original checkpoint.
56
+ - Continue using an Agent after a human rejects a Tool call. Model input for the
57
+ next turn omits the rejected, incomplete Tool exchange. The original journal
58
+ and public conversation history remain available for inspection.
59
+
60
+ ### Compatibility and verification
61
+
62
+ - Worker APIs require 0.32.0 or later; applications using `start_hosted` cannot
63
+ run against 0.31.0. The existing [0.31 storage and Agent Tool migration
64
+ requirements](docs/migrations/0.31.md) still apply. This release does not add a
65
+ persistence mapping migration or change those stored execution formats.
66
+ - Native handoff and completed-owner release cover one direct child in an
67
+ isolated dispatch batch. Delivery deduplication, exclusive claims, ACKs and
68
+ verified worker termination belong to the application. General graph transfer,
69
+ multi-host fencing, supervisor restart and recovery of unknown external Tool
70
+ effects are not established by these examples.
71
+ - Worker acceptance uses fixed HTTP responses, actual Ruby processes and SQLite,
72
+ including Ubuntu 24.04 and 26.04. It does not establish live-model acceptance.
73
+ - LM Studio/gpt-oss PEG parsing remains a known unresolved limitation. The latest
74
+ pre-release standard verifier reported 101 passed, 2 failed and 0 skipped;
75
+ Examples 01 and 22 received provider PEG errors, also reproduced before Worker
76
+ support. Known-error identification and bounded retry do not fix the provider's
77
+ root cause. These two failures are tracked separately from Worker acceptance.
78
+
15
79
  ## [0.31.0] - 2026-10-25
16
80
 
17
81
  ### Added
data/README.md CHANGED
@@ -37,7 +37,8 @@ tracking `main` directly.
37
37
 
38
38
  See [Features and Application Programming Interface (API) stability](docs/features.md) for the full feature matrix.
39
39
 
40
- The following table describes the application APIs available in v0.31.0.
40
+ The following table describes the application APIs available in v0.32.0.
41
+ Worker hosting and completed parent/child release require v0.32.0 or later.
41
42
  Review the [0.31 migration notes](docs/migrations/0.31.md) before upgrading
42
43
  persistent applications or custom Agent Tool subclasses.
43
44
 
@@ -48,7 +49,12 @@ persistent applications or custom Agent Tool subclasses.
48
49
  | Agent `transcript_messages` / `transcript_messages_async` | Beta | Read immutable conversation Message values with the same restoration rules as execution results; [reading conversation state](docs/getting-started.md) |
49
50
  | `Phronomy::GeneratorVerifier`, its approval/lifetime operations and immutable `Result` with output-failure diagnostics | Beta | [Generation and structured-output contract](docs/architecture/structured-output-and-scope.md) |
50
51
  | `Agent.open_scope`, `Agent::Scope`, `Agent::Base#unload_async / #unload!`, `AgentUnloadedError` and `Agent::ScopeCleanupError` | Beta | Runtime-bound ownership and safe release of idle Agent incarnations without changing saved history or closing the Store; [contract](docs/architecture/dx05-agent-scope.md) |
52
+ | `Agent::Base#start_hosted`, `Agent::HostedInvocation`, `Agent::ApprovalWait` and `Agent::Store#standalone_approval_wait` | Beta | Added in v0.32.0. Detach an exclusively owned standalone Agent at its first committed approval wait, then restore the same execution in another process; delivery claims and worker fencing remain application responsibilities; [contract](docs/runtime-and-concurrency.md#hosting-a-standalone-agent-through-approval) |
53
+ | `Agent::HostedInvocation#detach!(execution_id:, approval_request_id:)` | Beta | Added in v0.32.0. Release an exclusively owned Orchestrator and its single direct subagent at the child's approval wait. A second worker can resume both saved executions while the first worker handles other requests. [Contract](docs/runtime-and-concurrency.md#hosting-an-orchestrator-through-a-child-approval) |
54
+ | `MultiAgent::Orchestrator#approve_hosted_child` | Beta | Added in v0.32.0. Answer the current approval on a restored direct child. The returned handle can release the pair again if that child requests another approval during the same execution. [Contract](docs/runtime-and-concurrency.md#hosting-an-orchestrator-through-a-child-approval) |
55
+ | `MultiAgent::Orchestrator#unload_with_completed_child!` | Beta | Added in v0.32.0. Release local owners after a direct child result has been imported and the parent has finished. The saved executions, transcripts and coordination history remain available. [Contract](docs/runtime-and-concurrency.md#releasing-a-completed-orchestrator-and-child) |
51
56
  | `Tools::Agent::CleanupError` | Beta | Added in v0.31.0. Preserves the child, result and failure causes when safe release fails; retry `child_agent.unload_async` for release only; [contract](docs/architecture/agent-tool-child-lifetime.md) |
57
+ | `Workflow#send_saved_event` | Beta | Added in v0.32.0. Deliver an event to a specific revision of a persisted Workflow wait; [contract](docs/runtime-and-concurrency.md#delivering-an-event-to-a-saved-workflow-wait) |
52
58
  | `Workflow::Builder#task_state` and `WorkflowTaskResumeError` | Beta | State-entry-scoped Task completion, live cancellation and explicit restart rejection; [contract](docs/architecture/dx05-workflow-task-state.md) |
53
59
  | `Phronomy::Integrations::OrderedEventDelivery` | Beta | Optional ordered delivery with bounded admission and explicit drain lifetime; [usage](docs/ordered-event-delivery.md) |
54
60
  | `Phronomy::Error#code` and error constructor `code:` | Beta | Optional reason codes and bounded causes retained in saved failure diagnostics; [meaning and compatibility](docs/architecture/error-api-review.md) |
data/docs/features.md CHANGED
@@ -106,6 +106,8 @@ See the [migration guide](migrations/parallel-tool-chat-removal.md).
106
106
  | **Durable Handoff** — `MultiAgent::Handoff` and `MultiAgent::HandoffRunner` persist responsibility, immutable Context and exact Target reservation in one Persistence domain; current graph wiring is required for continuation | Beta |
107
107
  | **GeneratorVerifier** — Generator-Verifier loop with application prompts, Agent-validated output schemas and explicit output failures | Beta |
108
108
  | **`Phronomy::MultiAgent::Orchestrator`** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
109
+ | **Hosted child approvals** — An Orchestrator can release a single direct child and its parent at successive Tool approvals within the same child execution. The host transfers exclusive ownership between workers. [Contract](runtime-and-concurrency.md#hosting-an-orchestrator-through-a-child-approval) | Beta |
110
+ | **Completed native child release** — A host can unload the local Orchestrator and its finished direct child while preserving their saved results, conversation and coordination history. [Contract](runtime-and-concurrency.md#releasing-a-completed-orchestrator-and-child) | Beta |
109
111
  | **`Phronomy::MultiAgent::TeamCoordinator`** — LLM coordinator with stateful worker Agents | Beta |
110
112
  | **`Phronomy::MultiAgent::SharedState`** — Sequential peer-agent coordination through an invocation-local findings store; see the [namespace migration](migrations/shared-state-multi-agent.md) | Experimental |
111
113
  | **Human-in-the-loop approval** — durable suspension publishes `:approval_required` through the Agent listener; `approve` / `approve_async` resumes the same logical `execution_id` with a fresh FSMSession incarnation | Beta |
@@ -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,6 +12,27 @@ 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
+ # 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
+
15
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
@@ -210,6 +231,45 @@ module Phronomy
210
231
 
211
232
  private
212
233
 
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
+
213
273
  def _prepare_output_config(config, invocation_context, schema, schema_delivery)
214
274
  config = _prepare_invocation_config(config, invocation_context)
215
275
  if schema.nil?
@@ -752,7 +752,7 @@ module Phronomy
752
752
  true
753
753
  end
754
754
 
755
- def _prepare_invocation_config(config, invocation_context)
755
+ protected def _prepare_invocation_config(config, invocation_context)
756
756
  __assert_live_agent!
757
757
  _reject_removed_generic_identity_keys!(config)
758
758
  if config.keys.any? { |key| %w[output_schema schema_delivery response_schema phronomy_output_contract].include?(key.to_s) }
@@ -156,6 +156,10 @@ module Phronomy
156
156
  end
157
157
 
158
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
+
159
163
  task = @environment.submit(on_full: :raise) do
160
164
  execution = read_execution
161
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
 
@@ -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)