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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +13 -2
- data/docs/architecture/agent-tool-child-lifetime.md +141 -0
- data/docs/architecture/dx05-agent-scope.md +4 -2
- data/docs/architecture/structured-output-and-scope.md +73 -13
- data/docs/decisions/044-agent-default-and-one-shot-composition.md +5 -0
- data/docs/features.md +2 -0
- data/docs/getting-started.md +26 -0
- data/docs/migrations/0.31.md +72 -0
- data/docs/runtime-and-concurrency.md +159 -0
- data/lib/phronomy/agent/approval_wait.rb +8 -0
- data/lib/phronomy/agent/async_event_api.rb +77 -8
- data/lib/phronomy/agent/base.rb +28 -3
- data/lib/phronomy/agent/composition/run_once.rb +11 -10
- data/lib/phronomy/agent/context_preparation.rb +1 -0
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +1 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +9 -3
- data/lib/phronomy/agent/execution/execution_coordinator.rb +95 -2
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -6
- data/lib/phronomy/agent/execution/initial_preparation.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_actions.rb +17 -15
- data/lib/phronomy/agent/execution_change.rb +4 -0
- data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
- data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
- data/lib/phronomy/agent/output_contract.rb +47 -5
- data/lib/phronomy/agent/persistence/codec.rb +16 -2
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +1 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -2
- data/lib/phronomy/agent/runtime_input.rb +7 -0
- data/lib/phronomy/agent/store.rb +52 -0
- data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
- data/lib/phronomy/engine/fsm_session.rb +16 -3
- data/lib/phronomy/execution/async_operation.rb +16 -0
- data/lib/phronomy/execution/concurrency/result_finalization.rb +106 -0
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +3 -3
- data/lib/phronomy/generation/generator_verifier.rb +6 -3
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +1 -1
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +3 -1
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
- data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
- data/lib/phronomy/tools/agent.rb +58 -22
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +9 -0
- data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
- data/scripts/api_snapshot.rb +2 -0
- data/sig/phronomy/agent/execution_contract.rbs +13 -0
- data/sig/phronomy/agent/store.rbs +1 -0
- data/sig/phronomy/agent.rbs +8 -5
- data/sig/phronomy/execution/async_operation.rbs +1 -0
- data/sig/phronomy/generation/generator_verifier.rbs +1 -1
- data/sig/phronomy/multi_agent.rbs +2 -0
- data/sig/phronomy/tools/agent.rbs +14 -0
- data/sig/phronomy/workflow.rbs +1 -0
- 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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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?
|
|
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:)
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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,
|