phronomy 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -0
  3. data/README.md +13 -1
  4. data/RELEASE_CHECKLIST.md +4 -0
  5. data/docs/architecture/structured-output-and-scope.md +5 -4
  6. data/docs/architecture/trust-acceptance-followup.md +15 -0
  7. data/docs/context-policy-budgeting.md +108 -0
  8. data/docs/examples/shared_state_custom_sharing.rb +145 -0
  9. data/docs/features.md +4 -0
  10. data/docs/mcp-client.md +4 -2
  11. data/docs/migrations/shared-state-multi-agent.md +5 -0
  12. data/docs/runtime-and-concurrency.md +195 -0
  13. data/docs/shared-state-sharing.md +223 -0
  14. data/lib/phronomy/agent/approval_wait.rb +8 -0
  15. data/lib/phronomy/agent/async_event_api.rb +60 -0
  16. data/lib/phronomy/agent/base.rb +54 -1
  17. data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
  18. data/lib/phronomy/agent/execution/execution_coordinator.rb +100 -2
  19. data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
  20. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +67 -0
  21. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
  22. data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
  23. data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
  24. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
  25. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
  26. data/lib/phronomy/agent/store.rb +52 -0
  27. data/lib/phronomy/agent/tool_execution/tool_approval_request.rb +3 -2
  28. data/lib/phronomy/agent/tool_execution/tool_invocation.rb +12 -36
  29. data/lib/phronomy/context/context_policy.rb +11 -2
  30. data/lib/phronomy/context/context_policy_input.rb +26 -0
  31. data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
  32. data/lib/phronomy/engine/fsm_session.rb +9 -3
  33. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
  34. data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
  35. data/lib/phronomy/multi_agent/shared_state/binding.rb +44 -0
  36. data/lib/phronomy/multi_agent/shared_state/member_lifecycle_error.rb +32 -0
  37. data/lib/phronomy/multi_agent/shared_state.rb +147 -2
  38. data/lib/phronomy/tool/base.rb +69 -3
  39. data/lib/phronomy/tool/schema.rb +23 -11
  40. data/lib/phronomy/version.rb +1 -1
  41. data/lib/phronomy/workflow/execution/workflow.rb +9 -0
  42. data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
  43. data/scripts/api_snapshot.rb +8 -3
  44. data/sig/phronomy/agent/execution_contract.rbs +13 -0
  45. data/sig/phronomy/agent/store.rbs +1 -0
  46. data/sig/phronomy/agent.rbs +2 -0
  47. data/sig/phronomy/context.rbs +3 -0
  48. data/sig/phronomy/multi_agent.rbs +28 -2
  49. data/sig/phronomy/tool.rbs +1 -0
  50. data/sig/phronomy/workflow.rbs +1 -0
  51. metadata +9 -2
@@ -211,6 +211,37 @@ Worker authorization/execution outcomes return as values carrying
211
211
  `tool_invocation_id`; the Tool FSMSession consumes a mismatched semantic result
212
212
  without advancing its current state.
213
213
 
214
+ ## Tool approval display
215
+
216
+ A Tool chooses which arguments and derived facts a person may see. Agent asks
217
+ the Tool for both when it builds a new `ToolApprovalRequest` item and takes a
218
+ snapshot of the result. Approval policy and Tool execution use their own inputs.
219
+
220
+ For example, a Tool can declare `redact_params :token`. The default
221
+ `Tool::Base#approval_display(arguments:, facts:)` masks `token` in the
222
+ argument display and masks string values in facts. A numeric fact such as
223
+ `recipient_count: 3` can still be shown. Use fixed fact names and choose
224
+ aggregates that are appropriate to disclose.
225
+
226
+ If `redact_params :auth` covers `auth: {pin: 627018}`, the default also
227
+ masks a fact such as `echo: 627018`. It checks JSON values copied from inside
228
+ sensitive hashes and arrays. A matching fact key causes that entry to be
229
+ omitted. The declared
230
+ argument's own hash keys are treated as field names. If those names are
231
+ themselves confidential, keep them out of fact keys. Equality also masks
232
+ unrelated facts with the same value. The Tool author chooses which computed
233
+ values may be shown. For example, an arithmetic transformation of a PIN can
234
+ still disclose it even if the result differs from the original. Override
235
+ `approval_display` to explicitly select safe fields when publishing such facts.
236
+
237
+ To customize both fields, return a Hash with the symbol keys `:arguments` and
238
+ `:facts`, each holding a displayable Hash. The hook runs on EventLoop while
239
+ Agent prepares the approval. Build the result from the supplied values without
240
+ blocking or performing I/O. A wrapper can forward the operation to its
241
+ underlying Tool. If a Tool omits the operation, the item contains an
242
+ empty Hash for each field. Existing saved approval items retain the display
243
+ data recorded when they were created.
244
+
214
245
  ## Approval suspension and live owner lookup
215
246
 
216
247
  Approval suspension retains the same process-local Agent and AgentInvocation but
@@ -668,6 +699,170 @@ imply durable Agent continuation reconstruction.
668
699
  `Phronomy.reset_runtime!` exists primarily for test isolation and performs a real
669
700
  Runtime shutdown before resetting configuration.
670
701
 
702
+ ## Hosting a standalone Agent through approval
703
+
704
+ 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.
705
+
706
+ The hosted handle matches the requested approval to its saved wait and local
707
+ owner. That owner's execution coordinator submits the detach command to
708
+ EventLoop, which rechecks ownership before releasing the Agent. The host waits
709
+ for the result before handing the execution to another worker.
710
+
711
+ 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.
712
+
713
+ 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.
714
+
715
+ 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.
716
+
717
+ ## Hosting an Orchestrator through a child approval
718
+
719
+ A hosted Orchestrator can release its local Agent and one direct subagent when
720
+ the subagent is waiting for Tool approval. The parent execution has one reserved
721
+ child slot and its current Tool batch contains only the dispatch for that slot.
722
+ The host holds exclusive access to both Agents throughout the handoff. It
723
+ delivers work to the next worker only after release succeeds.
724
+
725
+ For example, worker A starts a parent with `start_hosted`. Its listener receives
726
+ the child's approval request. A host thread outside EventLoop passes the IDs
727
+ from that request to the parent handle.
728
+
729
+ ```ruby
730
+ wait = handle.detach!(execution_id: request.execution_id, approval_request_id: request.id)
731
+ ```
732
+
733
+ The returned `Agent::ApprovalWait` identifies the child execution, its approval
734
+ request and its saved revision. Keep the parent Agent and execution IDs with the
735
+ accepted request. Both old Agent objects reject later execution operations.
736
+ Worker A can keep unrelated Agent instances and process another request.
737
+ The parent's original result handle retains its
738
+ `ExecutionRehydrationRequiredError` outcome at this boundary.
739
+
740
+ Worker B loads the same parent class with its Agent Store, coordination Store
741
+ and listener. `parent.resume_async(parent_execution_id)` restores the pending
742
+ child and emits the same approval request. Compare its execution and request
743
+ IDs with the accepted request. The parent attempt reports
744
+ `ExecutionRehydrationRequiredError` while the child is waiting. Obtain the
745
+ restored child from its registered class by child Agent ID, call
746
+ `child.approve(child_execution_id, approval_request_id:, approved:)`, then
747
+ `parent.resume(parent_execution_id)` to import the result. A rejection is a
748
+ child outcome. The parent continues according to its own dispatch and Agent
749
+ logic.
750
+
751
+ If the child may need another handoff, the host answers the current approval
752
+ through the restored parent on a host thread outside EventLoop. This returns a
753
+ hosted handle for the child's operation. If the child finishes, the handle
754
+ yields the child result and the parent can import it. If the child requests
755
+ another approval, the host can use the handle to release the same pair.
756
+
757
+ For example, B approves the child's `apply_request` and the same child execution
758
+ then asks to call `publish_request`. B uses the first approval's ID to answer and
759
+ the second approval's ID to detach. The queue below receives the child approval
760
+ events from the listener installed when B loaded the parent. This fixture
761
+ requests a second approval within its test timeout.
762
+
763
+ ```ruby
764
+ require "timeout"
765
+
766
+ answer = parent.approve_hosted_child(parent_execution_id,
767
+ execution_id: first.execution_id, approval_request_id: first.id, approved: true)
768
+ second = Timeout.timeout(12) { approval_events.pop }
769
+ wait = answer.detach!(execution_id: second.execution_id, approval_request_id: second.id)
770
+ ```
771
+
772
+ After release, `answer.wait_result` returns the same child `ApprovalWait`.
773
+ B sends the existing parent and child execution IDs and the **new** approval
774
+ request ID to C. C restores the pair as B did and answers the new request.
775
+ The completed first Tool stays recorded in the child execution. While the hosted
776
+ answer is pending, read the saved execution to report progress. Calling
777
+ `parent.resume` again can advance the parent revision and invalidate the
778
+ handoff. Resume the parent after the child finishes. A host handling
779
+ arbitrary conversations must observe both approval events and handle completion
780
+ (`on_complete`), since the child can finish without requesting another Tool.
781
+
782
+ A later user request can also start a new hosted execution on the same parent
783
+ Agent. Its own child wait can be detached while the earlier terminal
784
+ execution's native slot history is still retained.
785
+
786
+ A wrong approval, another reserved child, overlapping local work, or a pending
787
+ cancellation prevents transfer. On `AgentBusyError` the host keeps ownership.
788
+ The child remains a coordinated execution in storage, including its original
789
+ slot and retention. Once both executions complete in B, these retentions
790
+ continue to guard ordinary `unload!`. This handoff preserves slot history.
791
+ An application that has finished using that history can separately call
792
+ `stores.multi_agent.forget_subagents(agent_id:, execution_id:)` to remove
793
+ that execution's subagent extension and release its holds.
794
+ The host serializes workers and owns request deduplication, claims, crash
795
+ recovery and acknowledgement.
796
+
797
+ ## Releasing a completed Orchestrator and child
798
+
799
+ A host can release the local owners once the parent has imported its direct
800
+ child's outcome and both executions have finished. Phronomy keeps the durable
801
+ reservation, execution results and both Agents' transcripts. A later worker
802
+ can load the parent by its Agent ID and use that conversation for another turn.
803
+
804
+ For example, after answering the last approval, the worker first lets the
805
+ parent import the child's result. It then passes both execution IDs to the
806
+ Orchestrator, on a host thread holding exclusive delivery for the pair. Call
807
+ this outside an application persistence transaction so the ownership decision
808
+ uses committed records.
809
+
810
+ ```ruby
811
+ result = parent.resume(parent_execution_id)
812
+ parent.unload_with_completed_child!(parent_execution_id,
813
+ execution_id: child_execution_id)
814
+ ```
815
+
816
+ The method returns true on release. The parent and any matching resident child
817
+ leave the local runtime registry together. Retrying on the released parent
818
+ object returns false. A parent reloaded on its own can use the saved pair's IDs
819
+ to unload without materializing the child. To display saved results without
820
+ loading either Agent, use the Agent Store.
821
+
822
+ A child may be complete while the parent is still waiting to import its result.
823
+ In that case release is premature. Keep the host claim, finish the parent and
824
+ then release. Active operations, a scoped owner or a conflicting retention can
825
+ also prevent release. The method covers one imported, directly registered child
826
+ of the specified execution. It does not sweep other child instances that the
827
+ application previously left resident.
828
+
829
+ Generic `unload!` and durable deletion continue to honor retention. Use
830
+ `forget_subagents` only when the application intends to remove the stored
831
+ coordination history. Once a host no longer needs an unloaded Agent, it can
832
+ drop its own references to that instance and its operation handles.
833
+
834
+ ## Delivering an event to a saved Workflow wait
835
+
836
+ A worker can restore a particular visit to a declared Workflow wait. The host holds
837
+ one claim for the Workflow instance, selects the matching Workflow definition,
838
+ and identifies the saved revision that should receive the event.
839
+
840
+ For example, a parent waiting for a review stores the child's Agent and execution
841
+ IDs in its context. A worker verifies the completed child result against those IDs.
842
+ While it owns the parent claim, it calls the following method with the revision
843
+ recorded for that wait:
844
+
845
+ ```ruby
846
+ workflow.send_saved_event(workflow_instance_id: parent_id,
847
+ expected_revision: wait_revision, event: :review_ready, input: {answer: answer})
848
+ ```
849
+
850
+ A later visit can have the same phase and fields but a different revision. The
851
+ earlier delivery raises `Persistence::StateConflictError` before its transition
852
+ executes. A transition whose guard rejects the input raises `ArgumentError` and
853
+ leaves the checkpoint available for a corrected event. After an
854
+ acknowledgement is lost, the host checks the durable parent state for evidence
855
+ of this child's result before acknowledging or retrying the message. It must
856
+ retain the original wait revision throughout retries.
857
+
858
+ The host keeps its claim from reading the parent through committing the transition.
859
+ Two workers could otherwise both run application actions before storage detects a
860
+ conflict. The application must also restore the same Workflow definition and
861
+ configuration. A revision identifies a stored state for one durable instance;
862
+ do not delete and reuse its ID while old deliveries might still arrive. The
863
+ revision does not identify deployed code or make an external Tool effect repeatable.
864
+ A pending in-process `task_state` cannot be restored with this operation.
865
+
671
866
  ## Further design records
672
867
 
673
868
  The `docs/decisions/` directory contains the historical and current Architecture
@@ -0,0 +1,223 @@
1
+ # Custom sharing in SharedState
2
+
3
+ SharedState can coordinate member Agents while an application owns the shared
4
+ data and the Tools that access it. The application provides one invocation-scoped
5
+ binding; SharedState owns member order, cycles, stopping decisions and final
6
+ aggregation. This Experimental extension is available since v0.33.0.
7
+
8
+ For example, an issue-review team can share records with `category` and `status`
9
+ fields. Its search Tool accepts those fields as separate arguments instead of
10
+ forcing the application to encode every record and condition as finding text.
11
+
12
+ ## Application provider and binding
13
+
14
+ A provider scopes shared resources around one synchronous team invocation. Its
15
+ binding contains an opaque store and a Tool-definition builder. SharedState
16
+ passes the store to application callbacks and does not require store methods or
17
+ a particular record schema.
18
+
19
+ For example, an application provider can open an issue board, yield its binding,
20
+ and close the board in `ensure`:
21
+
22
+ ```ruby
23
+ class IssueSharing
24
+ def open(input:, config:)
25
+ board = IssueBoard.new
26
+ yield Phronomy::MultiAgent::SharedState::Binding.new(
27
+ store: board,
28
+ tools: ->(member:, cycle:) { build_issue_tools(board, member, cycle) }
29
+ )
30
+ ensure
31
+ board&.close
32
+ end
33
+ end
34
+ ```
35
+
36
+ This excerpt shows the provider boundary. The
37
+ [complete executable application example](examples/shared_state_custom_sharing.rb)
38
+ defines its store, Tool builder, member Agent, team and offline check.
39
+
40
+ The provider must respond to `open(input:, config:)` and yield exactly one
41
+ `Phronomy::MultiAgent::SharedState::Binding` synchronously during that call.
42
+ Zero yields, multiple yields or an invalid binding violate the protocol and
43
+ raise `ArgumentError`. Do not retain the block for deferred execution. The
44
+ provider owns cleanup, including failures raised by member execution or
45
+ aggregation; SharedState does not call `close` on the opaque store.
46
+
47
+ `Binding.new(store:, tools:)` accepts a callable Tool builder. The public
48
+ `binding.store` returns the opaque store, and
49
+ `binding.tool_definitions(member:, cycle:)` calls the builder. `member` is the
50
+ registered Agent class; `cycle` is the current positive cycle number. The builder
51
+ must return a Hash whose keys are Tool classes and whose values are aliases or
52
+ `nil`, using the same Tool-definition shape as the existing Agent Tool API.
53
+ Arrays are not accepted. Invalid configuration or protocol values raise
54
+ `ArgumentError`.
55
+
56
+ ## Team composition and definition identity
57
+
58
+ A team selects its provider and supplies the application operations that consume
59
+ the shared store. The provider's stable ID and version identify the meaning of
60
+ its injected Tools independently from an individual member's definition.
61
+
62
+ For example, the complete issue-review example declares:
63
+
64
+ ```ruby
65
+ class ReviewTeam < Phronomy::MultiAgent::SharedState
66
+ sharing IssueSharing.new, id: "shared-issues", version: 1
67
+ member Researcher
68
+ max_cycles 2
69
+ coordination "Use search_issues to inspect relevant issues. " \
70
+ "Use record_issue to record each new issue with its category and status."
71
+ aggregate { |board| board.snapshot }
72
+ end
73
+ ```
74
+
75
+ Custom sharing requires an explicit `aggregate` block because SharedState does
76
+ not know how to read the application store. An optional `terminate_when` block
77
+ also receives that same store. Existing cycle order, stopping rules and result
78
+ shape remain in place. `config:` is provided to the sharing provider; this does
79
+ not introduce forwarding of `config:` to member invocations.
80
+
81
+ In custom mode, SharedState adds only the Tools returned by the application's
82
+ builder. It does not add `read_store` or `write_finding`, and it does not append
83
+ stored records to member prompts. The prompt consists of coordination text, the
84
+ task input and any member instruction. The member's existing Tools and aliases
85
+ remain available. Conflicting effective Tool names are rejected rather than
86
+ silently replacing an existing Tool.
87
+
88
+ Use a stable sharing ID and increment its version whenever the injected Tool
89
+ schemas or behavior change. Custom generated Agent identity incorporates that
90
+ ID/version alongside the member definition. A new store instance per invocation
91
+ does not by itself require a new sharing ID. The application must not substitute
92
+ different Tool semantics under an unchanged sharing revision.
93
+ The ID must be a nonempty String and the version must be a positive Integer.
94
+
95
+ The default mode remains available when `.sharing` is not configured. It retains
96
+ the existing invocation-local KnowledgeStore, built-in Tools, full-store prompt
97
+ behavior and instrumentation revision 1. This extension does not add a `query`
98
+ argument to the default `read_store` Tool or a search method to that store.
99
+
100
+ ## Deriving an Agent with a complete Tool set
101
+
102
+ The public `Agent::Base.with_tools(definitions, id:, version:)` factory creates
103
+ an Agent subclass with a replacement Tool map and an explicit definition
104
+ identity. Agent owns preservation of its authoring settings; SharedState uses
105
+ this public operation after composing the member and application Tools. It does
106
+ not need to read Agent private settings.
107
+
108
+ For example, inside the complete example's module, a provider can supply the
109
+ entire Tool map for a derived researcher class:
110
+
111
+ ```ruby
112
+ IssueSharing.new.open(input: "Review authentication", config: {}) do |binding|
113
+ tool_map = binding.tool_definitions(member: Researcher, cycle: 1)
114
+ derived = Researcher.with_tools(
115
+ tool_map, id: "issue-researcher-with-board", version: 1
116
+ )
117
+ # `derived` is a class; no Agent instance or Runtime resource was created.
118
+ end
119
+ ```
120
+
121
+ `definitions` is the complete Hash of Tool classes to aliases or `nil`; this
122
+ factory does not merge Tools or detect naming collisions. SharedState performs
123
+ those operations before calling it. A missing parent Tool is removed, and a
124
+ `nil` alias uses the Tool's own name even when the parent assigned an alias.
125
+ The supplied ID and version identify the derived definition directly.
126
+
127
+ The factory preserves Phronomy-authored model, provider, instructions,
128
+ ContextPolicy, generation settings, LLM-input hook and filters. Filter lists
129
+ are independent while configured filter and callback instances remain shared.
130
+ The original class is unchanged. This explicit factory does not change general
131
+ Agent subclass inheritance or define copying rules for application-specific
132
+ class state.
133
+
134
+ ## Application search and LLM input
135
+
136
+ Application Tools own record fields and search conditions. ContextPolicy and the
137
+ existing input-validation path continue to own LLM-input selection and budget
138
+ checks. A custom provider does not become another Context engine.
139
+
140
+ For example, `search_issues(category: "security", status: "open")` in the complete
141
+ example applies both field conditions and returns matching records as JSON. A
142
+ search can leave three records in the store while returning only one match. Its
143
+ Tool parameter types and enumerations use the existing Tool schema API.
144
+
145
+ The example has no result-size cap. A broad query may still produce a large Tool
146
+ result, and this extension does not make that result optional, rewrite recorded
147
+ Tool output or bypass the final input-budget check. Applications that need
148
+ paging, compaction or another acquisition policy must define that behavior
149
+ explicitly through the existing appropriate contracts.
150
+
151
+ SharedState continues to use the member's configured ContextPolicy. The binding
152
+ does not automatically populate `before_llm_input` candidates, inspect Context
153
+ internals or manage a provider message buffer. Example 22's adaptive reader is a
154
+ separate application implementation; this extension alone does not migrate it
155
+ or remove its existing private-API dependency.
156
+
157
+ ## Lifetime and verification
158
+
159
+ The provider owns resources for one synchronous invocation. SharedState does not
160
+ define a database schema, durable binding identity or process-transfer protocol
161
+ for the application's store. A live binding does not become a persisted
162
+ continuation. Custom sharing requires each member invocation to reach a terminal
163
+ state before coordination proceeds.
164
+
165
+ For example, the application file can check its store and Tools without calling
166
+ an LLM:
167
+
168
+ ```sh
169
+ bundle exec ruby docs/examples/shared_state_custom_sharing.rb --offline
170
+ ```
171
+
172
+ The check opens the provider, records three issues through `Tool#call`, searches
173
+ with both category and status, verifies one match and three retained records,
174
+ and verifies provider cleanup. It uses the real Tool argument-validation path.
175
+ It does not exercise the SharedState Agent loop or establish live-model,
176
+ database or multi-process acceptance.
177
+
178
+ In custom mode, SharedState releases terminal member instances through the
179
+ Agent's public `unload!` operation. A normal Agent approval wait keeps the public
180
+ `invoke` call pending. SharedState consequently stays inside the provider's
181
+ `open` block and retains its resources; it does not return a suspended result or
182
+ close the provider merely because an internal execution reaches an approval
183
+ wait. The application remains responsible for answering or cancelling approvals.
184
+
185
+ For example, when a member waits for approval of `record_issue`, the board stays
186
+ open while that invocation waits. An approval allows the Tool and member to
187
+ complete before aggregation and provider cleanup. A cancellation that ends the
188
+ member invocation also allows cleanup to run. The in-memory example's `ensure`
189
+ is scoped to the end of the invocation, not the start of an approval wait. This
190
+ is a live wait in the current process, not a way to release a Worker and restore
191
+ the board on another one.
192
+
193
+ The [SharedState integration suite](../spec/integration/shared_state_spec.rb)
194
+ separately exercises this approval lifecycle using a real Agent and fixed LLM
195
+ responses. It checks that the provider stays open and aggregation has not run
196
+ during the wait; after a public approval response, the Tool writes its record,
197
+ aggregation runs, the provider closes and the member is released. Fixed
198
+ responses verify the execution path, not a live model's Tool-selection behavior.
199
+
200
+ The lifecycle guard also detects a custom `invoke` override that returns a Hash
201
+ with a suspended status before reaching completion. That exceptional early
202
+ return raises `SharedState::MemberLifecycleError` with `reason: :suspended` and
203
+ stops coordination before later members or aggregation. It is not the behavior
204
+ of normal Agent HITL. SharedState does not approve, deny, cancel or unload the
205
+ pending execution on the application's behalf. Providers handling this case
206
+ must decide whether to retain their resources; merely retaining a binding adds
207
+ no SharedState resume operation.
208
+
209
+ `MemberLifecycleError#reason` also reports `:release_failed`. The error exposes
210
+ `agent`, the available `result`, `primary_error` and `release_error`. A failed
211
+ release preserves the Agent reference and both failure details when invocation
212
+ and release fail together, so the application can inspect the original failure
213
+ and decide whether to retry release. The provider's `open` scope can rescue this
214
+ error before its cleanup policy runs. Rescuing a coordination failure inside
215
+ `open` does not turn the invocation into success: SharedState still reports the
216
+ failure if the provider returns normally.
217
+
218
+ To run the team from an application, configure Phronomy's LLM backend normally,
219
+ set `PHRONOMY_EXAMPLE_MODEL` to that backend's model ID before requiring the file,
220
+ then invoke `SharedStateCustomSharingExample::ReviewTeam.new.invoke(task)`.
221
+ The example's fallback model name is a placeholder, not a remotely available
222
+ model. A DB-backed provider may replace the example's in-memory board, but its
223
+ transactions, isolation and recovery behavior remain application responsibilities.
@@ -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?
@@ -77,6 +77,8 @@ module Phronomy
77
77
 
78
78
  def tool_aliases
79
79
  own = @tool_aliases || {}
80
+ return own if @tool_aliases_replace_inherited
81
+
80
82
  if superclass.respond_to?(:tool_aliases)
81
83
  superclass.tool_aliases.merge(own)
82
84
  else
@@ -84,6 +86,29 @@ module Phronomy
84
86
  end
85
87
  end
86
88
 
89
+ # Derive an Agent definition with a complete replacement Tool map.
90
+ # Phronomy authoring settings and filters are preserved; mutable filter
91
+ # lists are independent, while configured callable instances are shared.
92
+ # No Agent instance or Runtime resource is created by this factory.
93
+ # @param definitions [Hash<Class, String, nil>] Tool classes and aliases
94
+ # @param id [String] identity for the derived Agent definition
95
+ # @param version [Integer] revision for the derived Agent definition
96
+ # @return [Class<Phronomy::Agent::Base>]
97
+ # @api public
98
+ def with_tools(definitions, id:, version:)
99
+ unless definitions.is_a?(Hash)
100
+ raise ArgumentError, "with_tools expects a Hash of ToolClass => alias_or_nil"
101
+ end
102
+
103
+ derived = Class.new(self)
104
+ derived.agent_definition(id: id, version: version)
105
+ copy_authoring_settings_to(derived)
106
+ copy_authoring_filters_to(derived)
107
+ derived.tools(definitions)
108
+ derived.instance_variable_set(:@tool_aliases_replace_inherited, true)
109
+ derived
110
+ end
111
+
87
112
  def provider(name = nil)
88
113
  if name
89
114
  @provider = name
@@ -263,6 +288,34 @@ module Phronomy
263
288
 
264
289
  private
265
290
 
291
+ def copy_authoring_settings_to(derived)
292
+ settings = {
293
+ model: model,
294
+ provider: provider,
295
+ instructions: instructions,
296
+ context_policy: context_policy,
297
+ temperature: temperature,
298
+ max_iterations: max_iterations,
299
+ cache_instructions: cache_instructions,
300
+ max_output_tokens: max_output_tokens,
301
+ before_llm_input: before_llm_input
302
+ }
303
+ settings.each do |name, value|
304
+ derived.public_send(name, value) unless value.nil?
305
+ end
306
+ # These getters inherit by ivar presence. An explicit nil preserves
307
+ # absence even if the source class is configured again later.
308
+ %i[provider instructions].each do |name|
309
+ derived.instance_variable_set(:"@#{name}", nil) if settings[name].nil?
310
+ end
311
+ end
312
+
313
+ def copy_authoring_filters_to(derived)
314
+ _class_input_filters.each { |filter| derived.input_filter(filter) }
315
+ _class_output_filters.each { |filter| derived.output_filter(filter) }
316
+ _class_tool_result_filters.each { |filter| derived.tool_result_filter(filter) }
317
+ end
318
+
266
319
  def __construct_owned_agent(owner_runtime, reserved_agent_id, *args, **kwargs, &block)
267
320
  instance = allocate
268
321
  instance.send(:__prepare_runtime_owner!, owner_runtime, reserved_agent_id)
@@ -752,7 +805,7 @@ module Phronomy
752
805
  true
753
806
  end
754
807
 
755
- def _prepare_invocation_config(config, invocation_context)
808
+ protected def _prepare_invocation_config(config, invocation_context)
756
809
  __assert_live_agent!
757
810
  _reject_removed_generic_identity_keys!(config)
758
811
  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?