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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +141 -0
- data/README.md +13 -1
- data/RELEASE_CHECKLIST.md +4 -0
- data/docs/architecture/structured-output-and-scope.md +5 -4
- data/docs/architecture/trust-acceptance-followup.md +15 -0
- data/docs/context-policy-budgeting.md +108 -0
- data/docs/examples/shared_state_custom_sharing.rb +145 -0
- data/docs/features.md +4 -0
- data/docs/mcp-client.md +4 -2
- data/docs/migrations/shared-state-multi-agent.md +5 -0
- data/docs/runtime-and-concurrency.md +195 -0
- data/docs/shared-state-sharing.md +223 -0
- data/lib/phronomy/agent/approval_wait.rb +8 -0
- data/lib/phronomy/agent/async_event_api.rb +60 -0
- data/lib/phronomy/agent/base.rb +54 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
- data/lib/phronomy/agent/execution/execution_coordinator.rb +100 -2
- data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
- data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +67 -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/recovery/recovery_coordinator/installation.rb +1 -1
- data/lib/phronomy/agent/store.rb +52 -0
- data/lib/phronomy/agent/tool_execution/tool_approval_request.rb +3 -2
- data/lib/phronomy/agent/tool_execution/tool_invocation.rb +12 -36
- data/lib/phronomy/context/context_policy.rb +11 -2
- data/lib/phronomy/context/context_policy_input.rb +26 -0
- data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
- data/lib/phronomy/engine/fsm_session.rb +9 -3
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
- data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
- data/lib/phronomy/multi_agent/shared_state/binding.rb +44 -0
- data/lib/phronomy/multi_agent/shared_state/member_lifecycle_error.rb +32 -0
- data/lib/phronomy/multi_agent/shared_state.rb +147 -2
- data/lib/phronomy/tool/base.rb +69 -3
- data/lib/phronomy/tool/schema.rb +23 -11
- 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 +8 -3
- data/sig/phronomy/agent/execution_contract.rbs +13 -0
- data/sig/phronomy/agent/store.rbs +1 -0
- data/sig/phronomy/agent.rbs +2 -0
- data/sig/phronomy/context.rbs +3 -0
- data/sig/phronomy/multi_agent.rbs +28 -2
- data/sig/phronomy/tool.rbs +1 -0
- data/sig/phronomy/workflow.rbs +1 -0
- 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.
|
|
@@ -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?
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -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?
|