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
|
@@ -21,6 +21,36 @@ module Phronomy
|
|
|
21
21
|
:approved, :config, :result_task
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
+
# A resumed approval must never be adopted by the original start handle.
|
|
25
|
+
# Only a hosted decision owns the right to detach its next suspension.
|
|
26
|
+
class ApprovalOperation < OperationResult
|
|
27
|
+
attr_reader :parent_wait
|
|
28
|
+
|
|
29
|
+
def initialize(agent:, name:, parent_wait:)
|
|
30
|
+
super(agent: agent, name: name)
|
|
31
|
+
@parent_wait = parent_wait
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The recovery runner uses this task for replay of a framework Tool batch.
|
|
36
|
+
# @api private
|
|
37
|
+
class FrameworkRecoveryTask < Phronomy::TaskResult
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Recovery publishes a receipt only after its own coordination commit.
|
|
41
|
+
# The receipt belongs to this incarnation; a new local attempt invalidates it.
|
|
42
|
+
RecoveredCoordinationWait = Data.define(:coordinator, :execution_id, :revision) do
|
|
43
|
+
def current? = coordinator.current_coordination_wait?(self)
|
|
44
|
+
end
|
|
45
|
+
private_constant :ApprovalOperation, :RecoveredCoordinationWait
|
|
46
|
+
attr_reader :recovered_coordination_wait
|
|
47
|
+
|
|
48
|
+
def current_coordination_wait?(wait)
|
|
49
|
+
environment = @agent.__execution_environment
|
|
50
|
+
@recovered_coordination_wait.equal?(wait) && environment.ownership.owned?(@agent) &&
|
|
51
|
+
!environment.registry.agent_execution_admitted?(@agent.agent_id)
|
|
52
|
+
end
|
|
53
|
+
|
|
24
54
|
# Recovery -> execution-owner commands. These stay on EventLoop and do
|
|
25
55
|
# not carry persistence readers or arbitrary private method names.
|
|
26
56
|
RecoverPreparationCommand = Data.define(
|
|
@@ -74,6 +104,8 @@ module Phronomy
|
|
|
74
104
|
TerminalCommitReady = Data.define(
|
|
75
105
|
:coordinator, :operation, :delivery, :outcome, :error
|
|
76
106
|
)
|
|
107
|
+
DetachHostedWait = Data.define(:coordinator, :wait, :task, :parent, :completion)
|
|
108
|
+
private_constant :DetachHostedWait
|
|
77
109
|
SuspensionCancelledCommand = Data.define(:coordinator, :execution_id)
|
|
78
110
|
|
|
79
111
|
DeferredTerminalCommand = Data.define(
|
|
@@ -125,11 +157,11 @@ module Phronomy
|
|
|
125
157
|
execution_id,
|
|
126
158
|
approval_request_id:,
|
|
127
159
|
approved:,
|
|
128
|
-
config: {}
|
|
160
|
+
config: {}, parent_wait: nil
|
|
129
161
|
)
|
|
130
162
|
@agent.send(:__assert_live_agent!)
|
|
131
163
|
@agent.send(:_reject_removed_generic_identity_keys!, config)
|
|
132
|
-
result_task =
|
|
164
|
+
result_task = ApprovalOperation.new(agent: @agent, parent_wait: parent_wait,
|
|
133
165
|
name: "agent-approval-resume:#{execution_id}")
|
|
134
166
|
command = ResumeCommand.new(
|
|
135
167
|
coordinator: self,
|
|
@@ -148,6 +180,16 @@ module Phronomy
|
|
|
148
180
|
result_task
|
|
149
181
|
end
|
|
150
182
|
|
|
183
|
+
# Submit the committed wait for final ownership checks on the EventLoop.
|
|
184
|
+
# @api private
|
|
185
|
+
def request_hosted_detach(wait, task:, parent:)
|
|
186
|
+
completion = Phronomy::TaskResult.deferred(name: "detach-hosted-approval")
|
|
187
|
+
command = DetachHostedWait.new(coordinator: self, wait: wait, task: task, parent: parent, completion: completion)
|
|
188
|
+
accepted = @agent.__execution_environment.registry.post(command, completion: completion)
|
|
189
|
+
raise Phronomy::RuntimeShutdownError, "EventLoop rejected hosted detach" unless accepted
|
|
190
|
+
completion
|
|
191
|
+
end
|
|
192
|
+
|
|
151
193
|
# Called only from an Agent FSMSession external-operation dispatch state on
|
|
152
194
|
# EventLoop. Each method captures one operation-specific immutable snapshot
|
|
153
195
|
# and sends only that snapshot to OffloadPool.
|
|
@@ -248,6 +290,8 @@ module Phronomy
|
|
|
248
290
|
apply_provider_dispatch_preparation_on_event_loop(command)
|
|
249
291
|
when ToolDispatchPreparationReady
|
|
250
292
|
apply_tool_dispatch_preparation_on_event_loop(command)
|
|
293
|
+
when DetachHostedWait
|
|
294
|
+
detach_hosted_wait(command, environment)
|
|
251
295
|
when ResumeCommand
|
|
252
296
|
begin_resume_on_event_loop(command)
|
|
253
297
|
when ResumeCommitReady
|
|
@@ -265,6 +309,47 @@ module Phronomy
|
|
|
265
309
|
|
|
266
310
|
private
|
|
267
311
|
|
|
312
|
+
# Called on EventLoop after the host has read the committed wait outside it.
|
|
313
|
+
def detach_hosted_wait(command, environment)
|
|
314
|
+
wait = command.wait
|
|
315
|
+
registry = environment.registry
|
|
316
|
+
state = registry.agent_execution_state(wait.execution_id)
|
|
317
|
+
binding = @suspended_cancellation
|
|
318
|
+
task = command.task || binding&.fetch(2)
|
|
319
|
+
parent = command.parent
|
|
320
|
+
if task.is_a?(ApprovalOperation) && (!command.task.equal?(task) || !task.parent_wait&.current?)
|
|
321
|
+
raise Phronomy::AgentBusyError, "This handle does not own the resumed child wait"
|
|
322
|
+
end
|
|
323
|
+
if parent && registry.agent_execution_admitted?(parent.agent_id)
|
|
324
|
+
raise Phronomy::AgentBusyError, "Hosted parent has another local execution"
|
|
325
|
+
end
|
|
326
|
+
unless state && state.agent.equal?(@agent) && state.execution.status == :suspended &&
|
|
327
|
+
state.execution.execution_revision == wait.execution_revision && state.fsm_session_id.nil? &&
|
|
328
|
+
state.execution.approval_request&.fetch("id", nil) == wait.approval_request_id &&
|
|
329
|
+
binding && task.is_a?(OperationResult) && binding[1].equal?(state.invocation) && binding[2].equal?(task) &&
|
|
330
|
+
!state.invocation.config[:cancellation_token]&.cancelled? && registry.agent_execution_quiescent?(wait.execution_id)
|
|
331
|
+
raise Phronomy::AgentBusyError, "Hosted operation no longer owns this approval wait"
|
|
332
|
+
end
|
|
333
|
+
registry.mark_agent_execution_admission(@agent.agent_id, execution_id: wait.execution_id,
|
|
334
|
+
state: :suspended, expected_state: :suspended)
|
|
335
|
+
task.reserve_approval_detach!(command, parent: parent)
|
|
336
|
+
begin
|
|
337
|
+
# release_agent_execution validates all remaining physical work before mutation.
|
|
338
|
+
registry.release_agent_execution(wait.execution_id)
|
|
339
|
+
rescue
|
|
340
|
+
environment.ownership.finish_unload(@agent, command, success: false, parent: parent)
|
|
341
|
+
raise
|
|
342
|
+
end
|
|
343
|
+
clear_suspended_cancellation
|
|
344
|
+
registry.release_agent_execution_admission(@agent.agent_id, execution_id: wait.execution_id)
|
|
345
|
+
waiters = registry.take_agent_completion_waiters(wait.execution_id, fallback: task)
|
|
346
|
+
environment.ownership.finish_unload(@agent, command, success: true, parent: parent)
|
|
347
|
+
waiters.each { |waiter| @agent.send(:_complete_result_task, waiter, wait) }
|
|
348
|
+
command.completion.complete(wait)
|
|
349
|
+
rescue => error
|
|
350
|
+
command.completion.fail(error)
|
|
351
|
+
end
|
|
352
|
+
|
|
268
353
|
# ----------------------------------------------------------------------
|
|
269
354
|
# Initial preparation
|
|
270
355
|
# ----------------------------------------------------------------------
|
|
@@ -296,6 +381,7 @@ module Phronomy
|
|
|
296
381
|
# must abort instead of deleting the Agent underneath this start.
|
|
297
382
|
@agent.send(:__assert_live_agent!)
|
|
298
383
|
|
|
384
|
+
@recovered_coordination_wait = nil
|
|
299
385
|
operation = capture_initial_preparation(request, root)
|
|
300
386
|
task = environment.submit(on_full: :raise) do
|
|
301
387
|
@initial_preparation.prepare(operation)
|
|
@@ -901,6 +987,12 @@ module Phronomy
|
|
|
901
987
|
return
|
|
902
988
|
end
|
|
903
989
|
|
|
990
|
+
if request.result_task.is_a?(ApprovalOperation) && (parent_wait = request.result_task.parent_wait)
|
|
991
|
+
unless parent_wait.current? && !@suspended_cancellation
|
|
992
|
+
return fail_task(request.result_task, Phronomy::AgentBusyError.new("Hosted decision requires a restored child wait"))
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
|
|
904
996
|
approval = state.execution.approval_request || {}
|
|
905
997
|
current_request_id = approval["id"] || approval[:id]
|
|
906
998
|
unless current_request_id.to_s == request.approval_request_id
|
|
@@ -1088,6 +1180,7 @@ module Phronomy
|
|
|
1088
1180
|
environment = @agent.__execution_environment
|
|
1089
1181
|
registry = environment.registry
|
|
1090
1182
|
state = recovered_execution_state!(command)
|
|
1183
|
+
@recovered_coordination_wait = nil
|
|
1091
1184
|
validate_recovered_continuation!(state.execution, command.continuation)
|
|
1092
1185
|
invocation = command.invocation
|
|
1093
1186
|
unless invocation && invocation.agent.equal?(@agent) &&
|
|
@@ -1495,6 +1588,11 @@ module Phronomy
|
|
|
1495
1588
|
delivery = ready.delivery
|
|
1496
1589
|
outcome = ready.outcome
|
|
1497
1590
|
release_terminal_ownership(environment, operation.execution_id, state)
|
|
1591
|
+
if delivery.result_task.is_a?(OperationResult)
|
|
1592
|
+
delivery.result_task.coordination_wait_execution_id = operation.execution_id
|
|
1593
|
+
elsif delivery.result_task.is_a?(FrameworkRecoveryTask)
|
|
1594
|
+
@recovered_coordination_wait = RecoveredCoordinationWait.new(self, operation.execution_id, outcome.execution.execution_revision)
|
|
1595
|
+
end
|
|
1498
1596
|
environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| fail_task(task, outcome.error) }
|
|
1499
1597
|
end
|
|
1500
1598
|
|
|
@@ -37,7 +37,12 @@ module Phronomy
|
|
|
37
37
|
# clear_transcript! does not remove Knowledge, while knowledge_cleared and
|
|
38
38
|
# context_reset invalidate earlier Knowledge records by Journal position.
|
|
39
39
|
def context_records
|
|
40
|
-
|
|
40
|
+
rejected_batches = rejected_tool_batches
|
|
41
|
+
messages = transcript_records.reject do |record|
|
|
42
|
+
%i[assistant_message tool_message].include?(record.kind) &&
|
|
43
|
+
record.llm_call_id && rejected_batches[record.execution_id] == record.llm_call_id
|
|
44
|
+
end
|
|
45
|
+
(messages + active_knowledge_records)
|
|
41
46
|
.sort_by { |record| [record.sequence || 0, record.record_id] }
|
|
42
47
|
.freeze
|
|
43
48
|
end
|
|
@@ -52,6 +57,24 @@ module Phronomy
|
|
|
52
57
|
|
|
53
58
|
private
|
|
54
59
|
|
|
60
|
+
# Rejection ends the execution at its final provider Tool batch. That
|
|
61
|
+
# exchange has no complete set of Tool replies to send to a provider.
|
|
62
|
+
# Identify it from existing journal facts, including pre-upgrade histories.
|
|
63
|
+
def rejected_tool_batches
|
|
64
|
+
rejected = {}
|
|
65
|
+
batches = {}
|
|
66
|
+
records.reverse_each do |record|
|
|
67
|
+
if record.kind == :execution_rejected
|
|
68
|
+
rejected[record.execution_id] = true
|
|
69
|
+
elsif record.kind == :assistant_message && rejected.delete(record.execution_id)
|
|
70
|
+
if Array(record.metadata["tool_call_ids"]).any? && record.llm_call_id
|
|
71
|
+
batches[record.execution_id] = record.llm_call_id
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
batches
|
|
76
|
+
end
|
|
77
|
+
|
|
55
78
|
def active_knowledge_records
|
|
56
79
|
reset_sequence = records.filter_map do |record|
|
|
57
80
|
record.sequence if KNOWLEDGE_RESET_KINDS.include?(record.kind)
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
owner = @environment.registry.agent_execution_owner(execution_id)
|
|
41
|
+
unless owner && (child ? owner.agent.persistence.equal?(@agent.persistence) : owner.agent.equal?(@agent))
|
|
42
|
+
raise Phronomy::AgentBusyError, "Hosted execution has no matching local owner"
|
|
43
|
+
end
|
|
44
|
+
completion = owner.coordinator.request_hosted_detach(wait,
|
|
45
|
+
task: (child && !@resumed_from) ? nil : @task, parent: child ? @agent : nil)
|
|
46
|
+
completion.wait_result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def matching_child_wait(execution_id, approval_request_id)
|
|
52
|
+
settled = @task.done?
|
|
53
|
+
wait = @child_approval_wait&.call(execution_id, approval_request_id)
|
|
54
|
+
return unless wait
|
|
55
|
+
|
|
56
|
+
# The child's event can precede the parent's committed coordination
|
|
57
|
+
# wait. Physical completion and that exact outcome authorize release.
|
|
58
|
+
@task.join
|
|
59
|
+
unless @task.coordination_wait_execution_id == @execution_id && @task.physical_complete?
|
|
60
|
+
@task.wait_result # Propagate a parent failure other than a committed wait.
|
|
61
|
+
raise Phronomy::AgentBusyError, "Hosted parent did not reach its coordination wait"
|
|
62
|
+
end
|
|
63
|
+
wait if settled || @child_approval_wait.call(execution_id, approval_request_id) == wait
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
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|
|
|
@@ -101,12 +101,13 @@ module Phronomy
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def self.build_item(invocation)
|
|
104
|
+
display = invocation.approval_display
|
|
104
105
|
Item.new(
|
|
105
106
|
tool_invocation_id: invocation.id,
|
|
106
107
|
tool_call_id: invocation.tool_call_id,
|
|
107
108
|
tool_name: invocation.tool_name,
|
|
108
|
-
arguments:
|
|
109
|
-
facts:
|
|
109
|
+
arguments: display.fetch(:arguments),
|
|
110
|
+
facts: display.fetch(:facts),
|
|
110
111
|
reason: invocation.authorization_reason,
|
|
111
112
|
origin: invocation.origin,
|
|
112
113
|
metadata: invocation.metadata
|
|
@@ -333,14 +333,21 @@ module Phronomy
|
|
|
333
333
|
@tool&.respond_to?(:parameters_schema) ? @tool.parameters_schema : {}
|
|
334
334
|
end
|
|
335
335
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
336
|
+
# The approval Item snapshots these fields at its own construction boundary.
|
|
337
|
+
# @api private
|
|
338
|
+
def approval_display
|
|
339
|
+
return {arguments: {}.freeze, facts: {}.freeze}.freeze unless @tool&.respond_to?(:approval_display)
|
|
339
340
|
|
|
340
|
-
|
|
341
|
-
|
|
341
|
+
display = @tool.approval_display(arguments: @arguments || @raw_arguments, facts: @facts)
|
|
342
|
+
unless Hash === display && Hash === display.fetch(:arguments, nil) && Hash === display.fetch(:facts, nil)
|
|
343
|
+
raise Phronomy::ConfigurationError, "Tool approval_display must return :arguments and :facts Hashes"
|
|
344
|
+
end
|
|
345
|
+
display
|
|
342
346
|
end
|
|
343
347
|
|
|
348
|
+
def display_arguments = immutable_copy(approval_display.fetch(:arguments))
|
|
349
|
+
def display_facts = immutable_copy(approval_display.fetch(:facts))
|
|
350
|
+
|
|
344
351
|
private
|
|
345
352
|
|
|
346
353
|
def restore_saved_state!(status, result)
|
|
@@ -544,37 +551,6 @@ module Phronomy
|
|
|
544
551
|
def immutable_copy(value)
|
|
545
552
|
Phronomy::Values::Immutable.copy(value)
|
|
546
553
|
end
|
|
547
|
-
|
|
548
|
-
def redact_for_display(value)
|
|
549
|
-
if @tool&.respond_to?(:redacted_args, true)
|
|
550
|
-
immutable_copy(@tool.send(:redacted_args, value || {}))
|
|
551
|
-
else
|
|
552
|
-
immutable_copy(value || {})
|
|
553
|
-
end
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
def sensitive_argument_values
|
|
557
|
-
return [] unless @tool&.class&.respond_to?(:redact_params)
|
|
558
|
-
normalized = (@arguments || @raw_arguments || {}).transform_keys(&:to_sym)
|
|
559
|
-
@tool.class.redact_params.filter_map { |name| normalized[name] }
|
|
560
|
-
end
|
|
561
|
-
|
|
562
|
-
def redact_value(value, sensitive_values)
|
|
563
|
-
return "[REDACTED]" if sensitive_values.any? { |sensitive| sensitive == value }
|
|
564
|
-
|
|
565
|
-
case value
|
|
566
|
-
when Hash
|
|
567
|
-
value.each_with_object({}) do |(key, item), result|
|
|
568
|
-
result[key] = redact_value(item, sensitive_values)
|
|
569
|
-
end.freeze
|
|
570
|
-
when Array
|
|
571
|
-
value.map { |item| redact_value(item, sensitive_values) }.freeze
|
|
572
|
-
when String
|
|
573
|
-
"[REDACTED]"
|
|
574
|
-
else
|
|
575
|
-
value
|
|
576
|
-
end
|
|
577
|
-
end
|
|
578
554
|
end
|
|
579
555
|
end
|
|
580
556
|
end
|
|
@@ -11,6 +11,16 @@ module Phronomy
|
|
|
11
11
|
|
|
12
12
|
protected
|
|
13
13
|
|
|
14
|
+
# Uses the same canonical representation and estimator as generated Policy
|
|
15
|
+
# items. Estimates are planning values, not a model tokenizer guarantee.
|
|
16
|
+
# @param content [Object] text or JSON-serializable content
|
|
17
|
+
# @return [Integer] estimated token count
|
|
18
|
+
# @api public
|
|
19
|
+
def estimate_tokens(content)
|
|
20
|
+
bytes = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
|
|
21
|
+
Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
|
|
22
|
+
end
|
|
23
|
+
|
|
14
24
|
def instruction_item(
|
|
15
25
|
content:,
|
|
16
26
|
role: :system,
|
|
@@ -113,8 +123,7 @@ module Phronomy
|
|
|
113
123
|
end
|
|
114
124
|
|
|
115
125
|
def estimate_content(content)
|
|
116
|
-
|
|
117
|
-
Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
|
|
126
|
+
estimate_tokens(content)
|
|
118
127
|
end
|
|
119
128
|
end
|
|
120
129
|
end
|