phronomy 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +7 -1
- data/docs/features.md +2 -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 +60 -0
- data/lib/phronomy/agent/base.rb +1 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
- data/lib/phronomy/agent/execution/execution_coordinator.rb +89 -2
- 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/recovery/recovery_coordinator/installation.rb +1 -1
- 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 +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/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 +1 -0
- data/sig/phronomy/multi_agent.rbs +2 -0
- data/sig/phronomy/workflow.rbs +1 -0
- metadata +4 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# An opt-in host handle. wait_result yields the ordinary terminal result or
|
|
6
|
+
# an ApprovalWait when the hosted operation itself is detached.
|
|
7
|
+
class HostedInvocation
|
|
8
|
+
def initialize(agent, execution_id, task, resumed_from: nil, &child_approval_wait)
|
|
9
|
+
@agent, @execution_id, @task = agent, execution_id, task
|
|
10
|
+
@environment = agent.__execution_environment
|
|
11
|
+
@resumed_from = resumed_from
|
|
12
|
+
@child_approval_wait = child_approval_wait
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def wait_result(timeout: nil) = @task.wait_result(timeout: timeout)
|
|
16
|
+
|
|
17
|
+
# Supports racing normal completion or failure with an approval event.
|
|
18
|
+
def on_complete(&callback)
|
|
19
|
+
@task.on_complete(&callback)
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def detach!(approval_request_id:, execution_id: @resumed_from&.execution_id || @execution_id)
|
|
24
|
+
if Phronomy::WaitPolicy.blocking_forbidden?
|
|
25
|
+
raise Phronomy::EventLoopReentrancyError, "detach! requires a host thread"
|
|
26
|
+
end
|
|
27
|
+
child = execution_id != @execution_id
|
|
28
|
+
wait = if @resumed_from
|
|
29
|
+
if execution_id == @resumed_from.execution_id && approval_request_id != @resumed_from.approval_request_id
|
|
30
|
+
@child_approval_wait.call(execution_id, approval_request_id)
|
|
31
|
+
end
|
|
32
|
+
elsif child
|
|
33
|
+
matching_child_wait(execution_id, approval_request_id)
|
|
34
|
+
else
|
|
35
|
+
@agent.persistence.standalone_approval_wait(agent_id: @agent.agent_id, execution_id: @execution_id)
|
|
36
|
+
end
|
|
37
|
+
unless wait && wait.approval_request_id == approval_request_id
|
|
38
|
+
raise Phronomy::AgentBusyError, "No matching hosted approval wait"
|
|
39
|
+
end
|
|
40
|
+
completion = Phronomy::TaskResult.deferred(name: "detach-hosted-approval")
|
|
41
|
+
owner = @environment.registry.agent_execution_owner(execution_id)
|
|
42
|
+
unless owner && (child ? owner.agent.persistence.equal?(@agent.persistence) : owner.agent.equal?(@agent))
|
|
43
|
+
raise Phronomy::AgentBusyError, "Hosted execution has no matching local owner"
|
|
44
|
+
end
|
|
45
|
+
command = ExecutionCoordinator::DetachHostedWait.new(coordinator: owner.coordinator, wait: wait,
|
|
46
|
+
task: (child && !@resumed_from) ? nil : @task, parent: child ? @agent : nil, completion: completion)
|
|
47
|
+
accepted = @environment.registry.post(command, completion: completion)
|
|
48
|
+
raise Phronomy::RuntimeShutdownError, "EventLoop rejected hosted detach" unless accepted
|
|
49
|
+
completion.wait_result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def matching_child_wait(execution_id, approval_request_id)
|
|
55
|
+
settled = @task.done?
|
|
56
|
+
wait = @child_approval_wait&.call(execution_id, approval_request_id)
|
|
57
|
+
return unless wait
|
|
58
|
+
|
|
59
|
+
# The child's event can precede the parent's committed coordination
|
|
60
|
+
# wait. Physical completion and that exact outcome authorize release.
|
|
61
|
+
@task.join
|
|
62
|
+
unless @task.coordination_wait_execution_id == @execution_id && @task.physical_complete?
|
|
63
|
+
@task.wait_result # Propagate a parent failure other than a committed wait.
|
|
64
|
+
raise Phronomy::AgentBusyError, "Hosted parent did not reach its coordination wait"
|
|
65
|
+
end
|
|
66
|
+
wait if settled || @child_approval_wait.call(execution_id, approval_request_id) == wait
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -29,6 +29,36 @@ module Phronomy
|
|
|
29
29
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
|
+
# The participant proves which durable retentions are finished history;
|
|
33
|
+
# Agent controls admission, idleness and release of the local owners.
|
|
34
|
+
def __unload_with_peer_execution!(execution_id, reserved_by:)
|
|
35
|
+
if Phronomy::WaitPolicy.blocking_forbidden?
|
|
36
|
+
raise Phronomy::EventLoopReentrancyError, "Unloading coordinated Agents requires a host thread"
|
|
37
|
+
end
|
|
38
|
+
return false if @_phronomy_runtime_owner_state == :unloaded
|
|
39
|
+
__assert_live_agent!
|
|
40
|
+
peer = persistence.execution_identity(execution_id)
|
|
41
|
+
unless peer.correlation&.slice(*reserved_by.keys) == reserved_by
|
|
42
|
+
raise Phronomy::AgentBusyError, "Execution does not belong to this coordination owner"
|
|
43
|
+
end
|
|
44
|
+
environment = __execution_environment
|
|
45
|
+
environment.ownership.unload_idle_pair(self, peer.agent_id) do |live_peer|
|
|
46
|
+
ids = [agent_id, peer.agent_id].sort
|
|
47
|
+
if ids.any? { |id| environment.existing_registry&.agent_execution_admitted?(id) }
|
|
48
|
+
raise Phronomy::AgentBusyError, "A coordinated Agent has a local execution"
|
|
49
|
+
end
|
|
50
|
+
persistence.transaction do |records, scope|
|
|
51
|
+
unless scope.outermost?
|
|
52
|
+
raise Phronomy::Persistence::TransactionError, "Releasing local owners requires an outermost transaction"
|
|
53
|
+
end
|
|
54
|
+
ids.each { |id| records.executions.assert_idle!(id) }
|
|
55
|
+
unless yield(peer, live_peer, scope)
|
|
56
|
+
raise Phronomy::AgentBusyError, "No matching completed coordination history"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
32
62
|
def __with_operation(async: false, read_only: false)
|
|
33
63
|
lease = __acquire_operation!(read_only: read_only)
|
|
34
64
|
followed = false
|
|
@@ -20,6 +20,13 @@ module Phronomy
|
|
|
20
20
|
ownership&.first&.release_operation(ownership[1], ownership[2])
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def reserve_approval_detach!(transition, parent: nil)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
raise Phronomy::AgentBusyError, "Hosted operation has ended" unless @registry
|
|
26
|
+
@registry.reserve_approval_detach!(@agent, @token, transition, parent: parent)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
23
30
|
def follow(task)
|
|
24
31
|
if task.respond_to?(:on_physical_complete)
|
|
25
32
|
task.on_physical_complete { release }
|
|
@@ -6,6 +6,9 @@ module Phronomy
|
|
|
6
6
|
# Only the domain runner acknowledges the end of its accepted operation.
|
|
7
7
|
# @api private
|
|
8
8
|
class OperationResult < Phronomy::Concurrency::PhysicalCompletionTask
|
|
9
|
+
# Published on the EventLoop before the task signals a committed coordination wait.
|
|
10
|
+
attr_accessor :coordination_wait_execution_id
|
|
11
|
+
|
|
9
12
|
def initialize(agent:, name:)
|
|
10
13
|
super(name: name)
|
|
11
14
|
@operation_lease = agent.send(:__acquire_operation!)
|
|
@@ -20,6 +23,15 @@ module Phronomy
|
|
|
20
23
|
release_if_quiet
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
def reserve_approval_detach!(transition, parent: nil)
|
|
27
|
+
@lifetime_mutex.synchronize do
|
|
28
|
+
if @finished || @released || !@work.zero?
|
|
29
|
+
raise Phronomy::AgentBusyError, "Hosted operation is still busy or has finished"
|
|
30
|
+
end
|
|
31
|
+
@operation_lease.reserve_approval_detach!(transition, parent: parent)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
23
35
|
def track(task)
|
|
24
36
|
@lifetime_mutex.synchronize { @work += 1 }
|
|
25
37
|
task.on_physical_complete do
|
|
@@ -165,12 +165,38 @@ module Phronomy
|
|
|
165
165
|
true
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
+
# Transfer only the single outstanding hosting operation, atomically with
|
|
169
|
+
# the same admission gate used by all other Agent operations.
|
|
170
|
+
def reserve_approval_detach!(agent, operation_token, transition, parent: nil)
|
|
171
|
+
@mutex.synchronize do
|
|
172
|
+
ensure_running!
|
|
173
|
+
entry = @entries[agent.agent_id]
|
|
174
|
+
operations = @operations[agent]
|
|
175
|
+
unless entry&.state == :live && entry.agent.equal?(agent) && !@scope_owners[agent.agent_id] &&
|
|
176
|
+
operations&.size == 1 && operations.key?(operation_token)
|
|
177
|
+
raise Phronomy::AgentBusyError, "Agent has another owner or accepted operation"
|
|
178
|
+
end
|
|
179
|
+
if parent
|
|
180
|
+
entry = @entries[parent.agent_id]
|
|
181
|
+
unless entry&.state == :live && entry.agent.equal?(parent) && !@scope_owners[parent.agent_id]
|
|
182
|
+
raise Phronomy::AgentBusyError, "Hosted parent no longer owns the local Agent"
|
|
183
|
+
end
|
|
184
|
+
assert_no_operations!(parent)
|
|
185
|
+
@entries[parent.agent_id] = Entry.new(state: :unloading, agent: parent, token: transition)
|
|
186
|
+
end
|
|
187
|
+
@entries[agent.agent_id] = Entry.new(state: :unloading, agent: agent, token: transition)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
168
191
|
# Returns the already accepted transition for duplicate callers.
|
|
169
192
|
def reserve_unload(agent)
|
|
170
193
|
@mutex.synchronize do
|
|
171
194
|
ensure_running!
|
|
172
195
|
entry = @entries[agent.agent_id]
|
|
173
196
|
if entry&.state == :unloading && entry.agent.equal?(agent)
|
|
197
|
+
unless entry.token.is_a?(UnloadTransition)
|
|
198
|
+
raise Phronomy::AgentBusyError, "Agent has a coordinated release in progress"
|
|
199
|
+
end
|
|
174
200
|
return [entry.token, false]
|
|
175
201
|
end
|
|
176
202
|
unless entry&.state == :live && entry.agent.equal?(agent)
|
|
@@ -183,22 +209,28 @@ module Phronomy
|
|
|
183
209
|
end
|
|
184
210
|
end
|
|
185
211
|
|
|
186
|
-
def finish_unload(agent, transition, success:)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
212
|
+
def finish_unload(agent, transition, success:, parent: nil)
|
|
213
|
+
owners = {agent.agent_id => agent}
|
|
214
|
+
owners[parent.agent_id] = parent if parent
|
|
215
|
+
finish_unload_entries(owners, transition, success: success)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Reserve the peer identity even when no instance is loaded. Its absence
|
|
219
|
+
# is then stable until the caller's durable checks commit or roll back.
|
|
220
|
+
def unload_idle_pair(parent, peer_id)
|
|
221
|
+
raise Phronomy::AgentBusyError, "Expected distinct Agent identities" if parent.agent_id == peer_id
|
|
222
|
+
transition = Object.new.freeze
|
|
223
|
+
# A host thread may exit during verification. Keep the short two-owner
|
|
224
|
+
# registry changes indivisible even in the presence of an async interrupt.
|
|
225
|
+
Thread.handle_interrupt(Object => :never) do
|
|
226
|
+
owners = reserve_idle_pair!(parent, peer_id, transition)
|
|
227
|
+
begin
|
|
228
|
+
Thread.handle_interrupt(Object => :immediate) { yield owners[peer_id] }
|
|
229
|
+
success = true
|
|
230
|
+
ensure
|
|
231
|
+
finish_unload_entries(owners, transition, success: success)
|
|
198
232
|
end
|
|
199
|
-
@condition.broadcast
|
|
200
233
|
end
|
|
201
|
-
true
|
|
202
234
|
end
|
|
203
235
|
|
|
204
236
|
def owned?(agent)
|
|
@@ -319,6 +351,46 @@ module Phronomy
|
|
|
319
351
|
|
|
320
352
|
private
|
|
321
353
|
|
|
354
|
+
def reserve_idle_pair!(parent, peer_id, transition)
|
|
355
|
+
@mutex.synchronize do
|
|
356
|
+
ensure_running!
|
|
357
|
+
owners = {parent.agent_id => parent, peer_id => @entries[peer_id]&.agent}
|
|
358
|
+
owners.each do |id, agent|
|
|
359
|
+
entry = @entries[id]
|
|
360
|
+
raise Phronomy::AgentBusyError, "Agent belongs to a scope" if @scope_owners[id]
|
|
361
|
+
next if entry.nil? && agent.nil?
|
|
362
|
+
unless entry&.state == :live && entry.agent.equal?(agent)
|
|
363
|
+
raise Phronomy::AgentBusyError, "Local Agent owner changed"
|
|
364
|
+
end
|
|
365
|
+
assert_no_operations!(agent)
|
|
366
|
+
end
|
|
367
|
+
owners.each { |id, agent| @entries[id] = Entry.new(state: :unloading, agent: agent, token: transition) }
|
|
368
|
+
owners
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def finish_unload_entries(owners, transition, success:)
|
|
373
|
+
@mutex.synchronize do
|
|
374
|
+
owners.each do |id, owner|
|
|
375
|
+
entry = @entries[id]
|
|
376
|
+
unless entry&.state == :unloading && entry.agent.equal?(owner) && entry.token.equal?(transition)
|
|
377
|
+
raise Phronomy::RuntimeShutdownError, "Agent unload reservation is no longer authoritative"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
owners.each do |id, owner|
|
|
381
|
+
if success || owner.nil?
|
|
382
|
+
owner&.send(:__mark_unloaded!)
|
|
383
|
+
@entries.delete(id)
|
|
384
|
+
@scope_owners.delete(id)
|
|
385
|
+
else
|
|
386
|
+
@entries[id] = Entry.new(state: :live, agent: owner, token: nil)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
@condition.broadcast
|
|
390
|
+
end
|
|
391
|
+
true
|
|
392
|
+
end
|
|
393
|
+
|
|
322
394
|
def reserve_create!(key, scope_owner: nil)
|
|
323
395
|
token = Object.new.freeze
|
|
324
396
|
@mutex.synchronize do
|
|
@@ -316,7 +316,7 @@ module Phronomy
|
|
|
316
316
|
main = agent.send(:execution_coordinator_for, agent.__execution_wiring)
|
|
317
317
|
|
|
318
318
|
if framework_batch?(execution)
|
|
319
|
-
internal =
|
|
319
|
+
internal = ExecutionCoordinator::FrameworkRecoveryTask.deferred(name: "framework-tool-recovery:#{execution.execution_id}")
|
|
320
320
|
continue_recovery_on_event_loop(execution, internal, material: material)
|
|
321
321
|
agent.send(:_complete_result_task, completion, agent)
|
|
322
322
|
return
|
data/lib/phronomy/agent/store.rb
CHANGED
|
@@ -108,6 +108,58 @@ module Phronomy
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
# Read without loading a live Agent. Missing or ineligible waits return nil.
|
|
112
|
+
def standalone_approval_wait(agent_id:, execution_id:, scope: nil)
|
|
113
|
+
approval_wait_for(agent_id: agent_id, execution_id: execution_id, correlation: nil, retentions: [], scope: scope)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Participant observation. An active coordinated wait must match both its
|
|
117
|
+
# exact reservation and every durable retention owned by the participant.
|
|
118
|
+
# @api private
|
|
119
|
+
def approval_wait_for(agent_id:, execution_id:, correlation:, retentions:, scope:)
|
|
120
|
+
with_scope(scope) do |records|
|
|
121
|
+
begin
|
|
122
|
+
execution = records.executions.load(execution_id)
|
|
123
|
+
rescue Phronomy::Persistence::NotFoundError
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
assert_execution_owner!(execution, agent_id)
|
|
127
|
+
next unless execution.status == :suspended && (id = execution.approval_request&.fetch("id", nil))
|
|
128
|
+
next unless execution.metadata["reservation"] == correlation && !execution.metadata["execution_extension"]
|
|
129
|
+
next if execution.metadata["cancellation_requested"] || records.cancellations.requested?(agent_id, execution_id)
|
|
130
|
+
next unless records.retentions.list(agent_id) == retentions
|
|
131
|
+
|
|
132
|
+
ApprovalWait.new(execution_id: execution.execution_id.dup.freeze, approval_request_id: id.dup.freeze,
|
|
133
|
+
execution_revision: execution.execution_revision)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A native participant can release a root only at a single replayable
|
|
138
|
+
# Tool dispatch. A mixed batch may still require external Tool evidence.
|
|
139
|
+
# @api private
|
|
140
|
+
def single_dispatch_wait?(agent_id:, execution_id:, invocation_id:, name:, scope:, expected_revision: nil)
|
|
141
|
+
with_scope(scope) do |records|
|
|
142
|
+
execution = records.executions.load(execution_id)
|
|
143
|
+
assert_execution_owner!(execution, agent_id)
|
|
144
|
+
next false if expected_revision && execution.execution_revision != expected_revision
|
|
145
|
+
batch = Array(execution.metadata[ExecutionMetadata::TOOL_BATCH_METADATA_KEY])
|
|
146
|
+
execution.status == :active && execution.phase.to_sym == :dispatching_tools &&
|
|
147
|
+
!execution.metadata["reservation"] && !execution.metadata["cancellation_requested"] &&
|
|
148
|
+
!records.cancellations.requested?(agent_id, execution_id) &&
|
|
149
|
+
batch.size == 1 && batch.first.values_at("tool_invocation_id", "tool_name", "status") == [invocation_id, name, "authorized"]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check a historical reference without reading its output or error content.
|
|
154
|
+
# @api private
|
|
155
|
+
def terminal_execution?(agent_id:, execution_id:, scope:, reservation: nil)
|
|
156
|
+
with_scope(scope) do |records|
|
|
157
|
+
execution = records.executions.load(execution_id)
|
|
158
|
+
assert_execution_owner!(execution, agent_id)
|
|
159
|
+
execution.terminal? && (reservation.nil? || execution.metadata["reservation"] == reservation)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
111
163
|
def active_executions(agent_id:, scope: nil)
|
|
112
164
|
with_scope(scope) do |records|
|
|
113
165
|
records.executions.list_active(agent_id).map do |execution|
|
|
@@ -147,8 +147,9 @@ module Phronomy
|
|
|
147
147
|
ordered = Array(items).sort_by { |item| [item.sequence || 0, item.id] }
|
|
148
148
|
assistants = ordered.select { |item| item.kind == :assistant_message }
|
|
149
149
|
tool_messages = ordered.select { |item| item.kind == :tool_message }
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
tool_messages_by_call = tool_messages.group_by { |item| tool_exchange_key(item, item.tool_call_id) }
|
|
151
|
+
tool_messages_by_call.each_value do |messages|
|
|
152
|
+
tool_call_id = messages.first.tool_call_id
|
|
152
153
|
if tool_call_id.nil? || tool_call_id.empty?
|
|
153
154
|
raise ArgumentError, "Tool message Context item requires tool_call_id"
|
|
154
155
|
end
|
|
@@ -160,11 +161,12 @@ module Phronomy
|
|
|
160
161
|
assistant_by_tool_call = {}
|
|
161
162
|
assistants.each do |assistant|
|
|
162
163
|
assistant.tool_call_ids.each do |tool_call_id|
|
|
163
|
-
|
|
164
|
+
key = tool_exchange_key(assistant, tool_call_id)
|
|
165
|
+
if assistant_by_tool_call.key?(key)
|
|
164
166
|
raise ArgumentError,
|
|
165
167
|
"duplicate assistant Tool Call id in Context Policy input: #{tool_call_id}"
|
|
166
168
|
end
|
|
167
|
-
assistant_by_tool_call[
|
|
169
|
+
assistant_by_tool_call[key] = assistant
|
|
168
170
|
end
|
|
169
171
|
end
|
|
170
172
|
|
|
@@ -173,26 +175,25 @@ module Phronomy
|
|
|
173
175
|
ordered.each do |item|
|
|
174
176
|
next if claimed[item.id]
|
|
175
177
|
|
|
176
|
-
if item.kind == :assistant_message && !item.tool_call_ids.empty?
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
assistant = if item.kind == :assistant_message && !item.tool_call_ids.empty?
|
|
179
|
+
item
|
|
180
|
+
elsif item.kind == :tool_message
|
|
181
|
+
assistant_by_tool_call.fetch(tool_exchange_key(item, item.tool_call_id)) do
|
|
182
|
+
raise ArgumentError, "orphan Tool message in Context Policy input: #{item.tool_call_id}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
group = if assistant
|
|
187
|
+
messages = assistant.tool_call_ids.map do |tool_call_id|
|
|
188
|
+
key = tool_exchange_key(assistant, tool_call_id)
|
|
189
|
+
tool_messages_by_call.fetch(key) do
|
|
179
190
|
raise ArgumentError,
|
|
180
191
|
"assistant Tool Call has no Tool message in Context Policy input: #{tool_call_id}"
|
|
181
192
|
end.first
|
|
182
193
|
end
|
|
183
|
-
|
|
184
|
-
elsif item.kind == :tool_message
|
|
185
|
-
assistant = assistant_by_tool_call[item.tool_call_id]
|
|
186
|
-
unless assistant
|
|
187
|
-
raise ArgumentError,
|
|
188
|
-
"orphan Tool message in Context Policy input: #{item.tool_call_id}"
|
|
189
|
-
end
|
|
190
|
-
next if claimed[assistant.id]
|
|
191
|
-
group = ([assistant] + assistant.tool_call_ids.map do |tool_call_id|
|
|
192
|
-
tool_messages_by_id.fetch(tool_call_id).first
|
|
193
|
-
end).uniq.sort_by { |member| [member.sequence || 0, member.id] }
|
|
194
|
+
[assistant, *messages].sort_by { |member| [member.sequence || 0, member.id] }
|
|
194
195
|
else
|
|
195
|
-
|
|
196
|
+
[item]
|
|
196
197
|
end
|
|
197
198
|
|
|
198
199
|
group.each { |member| claimed[member.id] = true }
|
|
@@ -202,6 +203,13 @@ module Phronomy
|
|
|
202
203
|
mark_framework_required(groups, execution_id: execution_id, call_mode: call_mode)
|
|
203
204
|
end
|
|
204
205
|
|
|
206
|
+
# Providers can number Tool calls afresh for each LLM response. Both native
|
|
207
|
+
# journal records carry the same durable LLM call ID, including after reload.
|
|
208
|
+
def tool_exchange_key(item, tool_call_id)
|
|
209
|
+
origin = item.provenance
|
|
210
|
+
[origin.agent_id, origin.execution_id, origin.llm_call_id, tool_call_id]
|
|
211
|
+
end
|
|
212
|
+
|
|
205
213
|
def mark_framework_required(groups, execution_id:, call_mode:)
|
|
206
214
|
return groups.map(&:freeze).freeze unless call_mode.to_sym == :complete
|
|
207
215
|
|
|
@@ -96,6 +96,7 @@ module Phronomy
|
|
|
96
96
|
recursion_limit_scope: :session,
|
|
97
97
|
resume_event: nil,
|
|
98
98
|
resume_phase: nil,
|
|
99
|
+
require_resume_transition: false,
|
|
99
100
|
stable_observer: nil,
|
|
100
101
|
context_metadata: {},
|
|
101
102
|
event_sink: nil,
|
|
@@ -132,6 +133,7 @@ module Phronomy
|
|
|
132
133
|
@event_loop = event_loop
|
|
133
134
|
@resume_event = resume_event
|
|
134
135
|
@resume_phase = resume_phase
|
|
136
|
+
@require_resume_transition = require_resume_transition
|
|
135
137
|
@stable_observer = stable_observer
|
|
136
138
|
@terminal_policy = terminal_policy
|
|
137
139
|
@terminal_lifecycle_state = :running
|
|
@@ -153,7 +155,8 @@ module Phronomy
|
|
|
153
155
|
type: @resume_event,
|
|
154
156
|
target_id: @id,
|
|
155
157
|
payload: nil
|
|
156
|
-
)
|
|
158
|
+
),
|
|
159
|
+
require_transition: @require_resume_transition
|
|
157
160
|
)
|
|
158
161
|
else
|
|
159
162
|
@current_state = @entry_point
|
|
@@ -242,7 +245,7 @@ module Phronomy
|
|
|
242
245
|
end
|
|
243
246
|
end
|
|
244
247
|
|
|
245
|
-
def fire_and_advance!(event)
|
|
248
|
+
def fire_and_advance!(event, require_transition: false)
|
|
246
249
|
automatic = event.type == :state_completed
|
|
247
250
|
exhausted = if @recursion_limit_scope == :automatic_chain
|
|
248
251
|
automatic && @automatic_steps >= @recursion_limit
|
|
@@ -258,7 +261,10 @@ module Phronomy
|
|
|
258
261
|
clear_selected_transition!
|
|
259
262
|
@tracker.current_event = event if @tracker.respond_to?(:current_event=)
|
|
260
263
|
transitioned = fire_event!(@tracker, event.type, @current_state)
|
|
261
|
-
|
|
264
|
+
unless transitioned
|
|
265
|
+
raise ArgumentError, "Event #{event.type.inspect} was rejected at #{@current_state.inspect}" if require_transition
|
|
266
|
+
return
|
|
267
|
+
end
|
|
262
268
|
|
|
263
269
|
@ctx = @tracker.context
|
|
264
270
|
@context = @ctx
|
|
@@ -83,6 +83,91 @@ module Phronomy
|
|
|
83
83
|
extension&.state_ref ? persistence.contents.fetch_json(extension.state_ref) : {"children" => [], "cancel_requested" => false}
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# Validate both ends of the reservation in one durable snapshot. Only
|
|
87
|
+
# one direct, unfinished child belongs to this hosted handoff.
|
|
88
|
+
def child_approval_wait(parent_id, parent_execution_id, execution_id, approval_id, parent_revision: nil, live_child: nil)
|
|
89
|
+
store = persistence.agent_store
|
|
90
|
+
persistence.coordinator.atomic do |scope|
|
|
91
|
+
extension = store.execution_extension(agent_id: parent_id, execution_id: parent_execution_id,
|
|
92
|
+
binding_key: BINDING_KEY, scope: scope)
|
|
93
|
+
next unless extension&.state_ref && extension.binding_version == 1
|
|
94
|
+
snapshot = state_for(extension)
|
|
95
|
+
children = snapshot.fetch("children")
|
|
96
|
+
next unless !snapshot["cancel_requested"] && children.size == 1
|
|
97
|
+
child = children.first
|
|
98
|
+
next unless !child.fetch("imported") && child.fetch("execution_id") == execution_id
|
|
99
|
+
next unless matching_child_class?(child, live_child)
|
|
100
|
+
next unless store.single_dispatch_wait?(agent_id: parent_id, execution_id: parent_execution_id,
|
|
101
|
+
invocation_id: child.fetch("slot"), name: "dispatch_to_#{child.fetch("name")}", expected_revision: parent_revision, scope: scope)
|
|
102
|
+
next unless hosted_retentions?(parent_id, parent_execution_id, scope)
|
|
103
|
+
|
|
104
|
+
child_id = child.fetch("agent_id")
|
|
105
|
+
owner = {"kind" => "subagent", "parent_execution_id" => parent_execution_id,
|
|
106
|
+
"parent_agent_id" => parent_id, "slot" => child.fetch("slot")}
|
|
107
|
+
retention = Phronomy::Agent::Retention.new(agent_id: child_id, execution_id: execution_id,
|
|
108
|
+
owner_key: "subagent:#{parent_execution_id}")
|
|
109
|
+
wait = store.approval_wait_for(agent_id: child_id, execution_id: execution_id,
|
|
110
|
+
correlation: owner, retentions: [retention], scope: scope)
|
|
111
|
+
wait if wait&.approval_request_id == approval_id
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A terminal record alone does not show that its result reached the
|
|
116
|
+
# parent. Check the native slot, exact reservation and holds together.
|
|
117
|
+
def completed_child_pair?(parent_id, parent_execution_id, child, live_child:, scope:)
|
|
118
|
+
store = persistence.agent_store
|
|
119
|
+
return false unless store.terminal_execution?(agent_id: parent_id, execution_id: parent_execution_id, scope: scope)
|
|
120
|
+
children = stored_children(parent_id, parent_execution_id, scope)
|
|
121
|
+
return false unless children&.size == 1
|
|
122
|
+
slot = children.first
|
|
123
|
+
return false unless slot.fetch("imported") == true && slot.fetch("agent_id") == child.agent_id &&
|
|
124
|
+
slot.fetch("execution_id") == child.execution_id && matching_child_class?(slot, live_child)
|
|
125
|
+
|
|
126
|
+
owner = {"kind" => "subagent", "parent_agent_id" => parent_id, "parent_execution_id" => parent_execution_id,
|
|
127
|
+
"slot" => slot.fetch("slot")}
|
|
128
|
+
return false unless store.terminal_execution?(agent_id: child.agent_id, execution_id: child.execution_id,
|
|
129
|
+
reservation: owner, scope: scope)
|
|
130
|
+
hold = Phronomy::Agent::Retention.new(agent_id: child.agent_id, execution_id: child.execution_id,
|
|
131
|
+
owner_key: "subagent:#{parent_execution_id}")
|
|
132
|
+
return false unless store.retained_references(agent_id: child.agent_id, scope: scope) == [hold]
|
|
133
|
+
|
|
134
|
+
hosted_retentions?(parent_id, parent_execution_id, scope) do |prior_id|
|
|
135
|
+
prior = stored_children(parent_id, prior_id, scope)
|
|
136
|
+
prior && !prior.empty? && prior.all? { |entry| entry.fetch("imported") == true }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private def stored_children(parent_id, execution_id, scope)
|
|
141
|
+
extension = persistence.agent_store.execution_extension(agent_id: parent_id, execution_id: execution_id,
|
|
142
|
+
binding_key: BINDING_KEY, scope: scope)
|
|
143
|
+
state_for(extension).fetch("children") if extension&.state_ref && extension.binding_version == 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private def matching_child_class?(slot, live_child)
|
|
147
|
+
klass = self.class.registered_child_class(@agent_class, slot)
|
|
148
|
+
klass && (!live_child || (live_child.agent_id == slot.fetch("agent_id") && live_child.is_a?(klass) &&
|
|
149
|
+
live_child.class.agent_definition == klass.agent_definition && live_child.persistence.equal?(persistence.agent_store)))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Retaining the history of an earlier completed native dispatch on this
|
|
153
|
+
# same Agent does not give anyone a live interest in the current run.
|
|
154
|
+
private def hosted_retentions?(agent_id, execution_id, scope)
|
|
155
|
+
store = persistence.agent_store
|
|
156
|
+
current = Phronomy::Agent::Retention.new(agent_id: agent_id, execution_id: execution_id, owner_key: "subagent:#{execution_id}")
|
|
157
|
+
retentions = store.retained_references(agent_id: agent_id, scope: scope)
|
|
158
|
+
retentions.include?(current) && (retentions - [current]).all? do |retention|
|
|
159
|
+
retention.execution_id && retention.owner_key == "subagent:#{retention.execution_id}" &&
|
|
160
|
+
store.terminal_execution?(agent_id: agent_id, execution_id: retention.execution_id, scope: scope) &&
|
|
161
|
+
(!block_given? || yield(retention.execution_id))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.registered_child_class(parent_class, child)
|
|
166
|
+
settings = parent_class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
|
|
167
|
+
klass = settings&.fetch(:agent_class)
|
|
168
|
+
klass if klass && klass.agent_definition.transform_keys(&:to_s) == child.fetch("definition")
|
|
169
|
+
end
|
|
170
|
+
|
|
86
171
|
def self.start(parent:, tool_invocation_id:, parent_execution_id:, config:)
|
|
87
172
|
completion = Phronomy::TaskResult.deferred(name: "durable-subagent:#{tool_invocation_id}")
|
|
88
173
|
preparation = Phronomy::Execution.submit(on_full: :raise) do
|
|
@@ -92,11 +177,8 @@ module Phronomy
|
|
|
92
177
|
snapshot = store.contents.fetch_json(extension.state_ref)
|
|
93
178
|
child = snapshot.fetch("children").find { |entry| entry.fetch("slot") == tool_invocation_id }
|
|
94
179
|
raise Phronomy::ExecutionRehydrationRequiredError, "Missing reserved child slot" unless child
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
raise Phronomy::ConfigurationError, "Registered child definition changed"
|
|
98
|
-
end
|
|
99
|
-
klass = settings.fetch(:agent_class)
|
|
180
|
+
klass = registered_child_class(parent.class, child)
|
|
181
|
+
raise Phronomy::ConfigurationError, "Registered child definition changed" unless klass
|
|
100
182
|
id = child.fetch("agent_id")
|
|
101
183
|
retention = Phronomy::Agent::Retention.new(agent_id: id, execution_id: child.fetch("execution_id"), owner_key: "subagent:#{parent_execution_id}")
|
|
102
184
|
agent = klass.get(id)
|
|
@@ -29,6 +29,16 @@ module Phronomy
|
|
|
29
29
|
@participant = @coordination_store && DurableSubagentCoordinator.new(agent_class: self.class, persistence: @coordination_store)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Release the local parent and its directly dispatched child after the
|
|
33
|
+
# child's outcome has been imported. The stored history remains intact.
|
|
34
|
+
# @api public
|
|
35
|
+
def unload_with_completed_child!(parent_execution_id, execution_id:)
|
|
36
|
+
owner = {"kind" => "subagent", "parent_agent_id" => agent_id, "parent_execution_id" => parent_execution_id}
|
|
37
|
+
__unload_with_peer_execution!(execution_id, reserved_by: owner) do |child, live_child, scope|
|
|
38
|
+
@participant&.completed_child_pair?(agent_id, parent_execution_id, child, live_child: live_child, scope: scope)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
# Current composition, never serialized in an execution record.
|
|
33
43
|
def __invocation_config(config)
|
|
34
44
|
if !self.class.registered_subagents.empty? && !@participant
|
|
@@ -38,6 +48,14 @@ module Phronomy
|
|
|
38
48
|
phronomy_execution_participant: @participant).compact
|
|
39
49
|
end
|
|
40
50
|
|
|
51
|
+
# Answers the current wait of a recovered direct child. If the same
|
|
52
|
+
# execution suspends again the host can release the pair with this handle.
|
|
53
|
+
# @api public
|
|
54
|
+
def approve_hosted_child(parent_execution_id, execution_id:, approval_request_id:, approved: true, config: {})
|
|
55
|
+
resume_hosted_child_approval(parent_execution_id, execution_id: execution_id,
|
|
56
|
+
approval_request_id: approval_request_id, approved: approved, config: config)
|
|
57
|
+
end
|
|
58
|
+
|
|
41
59
|
def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
|
|
42
60
|
# A subagent Tool is logically asynchronous: ToolInvocation starts the
|
|
43
61
|
# child Agent and resumes when its completion TaskResult settles. It must not
|
|
@@ -224,6 +242,8 @@ module Phronomy
|
|
|
224
242
|
|
|
225
243
|
private
|
|
226
244
|
|
|
245
|
+
def hosted_child_approval_reader = @participant&.method(:child_approval_wait)
|
|
246
|
+
|
|
227
247
|
def prepare_tool_class(tool_class, invocation: nil)
|
|
228
248
|
prepared = super
|
|
229
249
|
return prepared unless self.class._subagent_tool_classes.include?(tool_class)
|
|
@@ -234,28 +254,20 @@ module Phronomy
|
|
|
234
254
|
end&.last
|
|
235
255
|
inherits_knowledge = registration ? registration.fetch(:inherit_knowledge, true) : true
|
|
236
256
|
|
|
237
|
-
|
|
257
|
+
context = {parent: self}
|
|
238
258
|
# Invocation-owned Tools inherit the durable child slot's knowledge,
|
|
239
259
|
# captured by the existing preparation transaction off EventLoop.
|
|
240
|
-
|
|
260
|
+
context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
|
|
241
261
|
if invocation
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
end
|
|
245
|
-
captured_context.freeze
|
|
246
|
-
|
|
247
|
-
effective_name = prepared.new.name
|
|
248
|
-
Class.new(prepared) do
|
|
249
|
-
tool_name effective_name
|
|
250
|
-
define_method(:call) do |args, **kwargs|
|
|
251
|
-
self._orchestrator_context = captured_context
|
|
252
|
-
super(args, **kwargs)
|
|
253
|
-
end
|
|
254
|
-
define_method(:call_async) do |args, **kwargs|
|
|
255
|
-
self._orchestrator_context = captured_context
|
|
256
|
-
super(args, **kwargs)
|
|
257
|
-
end
|
|
262
|
+
context[:config] = invocation.config
|
|
263
|
+
context[:invocation_context] = invocation.config[:invocation_context]
|
|
258
264
|
end
|
|
265
|
+
|
|
266
|
+
# Cached Tool methods can outlive an execution. Only the instance used
|
|
267
|
+
# for this dispatch should hold its parent and invocation configuration.
|
|
268
|
+
tool = prepared.new
|
|
269
|
+
tool._orchestrator_context = context.freeze
|
|
270
|
+
tool
|
|
259
271
|
end
|
|
260
272
|
|
|
261
273
|
def active_knowledge_snapshot = knowledge_snapshot
|