phronomy 0.30.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +13 -2
- data/docs/architecture/agent-tool-child-lifetime.md +141 -0
- data/docs/architecture/dx05-agent-scope.md +4 -2
- data/docs/architecture/structured-output-and-scope.md +73 -13
- data/docs/decisions/044-agent-default-and-one-shot-composition.md +5 -0
- data/docs/features.md +2 -0
- data/docs/getting-started.md +26 -0
- data/docs/migrations/0.31.md +72 -0
- data/docs/runtime-and-concurrency.md +159 -0
- data/lib/phronomy/agent/approval_wait.rb +8 -0
- data/lib/phronomy/agent/async_event_api.rb +77 -8
- data/lib/phronomy/agent/base.rb +28 -3
- data/lib/phronomy/agent/composition/run_once.rb +11 -10
- data/lib/phronomy/agent/context_preparation.rb +1 -0
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +1 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +9 -3
- data/lib/phronomy/agent/execution/execution_coordinator.rb +95 -2
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -6
- data/lib/phronomy/agent/execution/initial_preparation.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_actions.rb +17 -15
- data/lib/phronomy/agent/execution_change.rb +4 -0
- data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
- data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
- data/lib/phronomy/agent/output_contract.rb +47 -5
- data/lib/phronomy/agent/persistence/codec.rb +16 -2
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +1 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -2
- data/lib/phronomy/agent/runtime_input.rb +7 -0
- data/lib/phronomy/agent/store.rb +52 -0
- data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
- data/lib/phronomy/engine/fsm_session.rb +16 -3
- data/lib/phronomy/execution/async_operation.rb +16 -0
- data/lib/phronomy/execution/concurrency/result_finalization.rb +106 -0
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +3 -3
- data/lib/phronomy/generation/generator_verifier.rb +6 -3
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +1 -1
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +3 -1
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
- data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
- data/lib/phronomy/tools/agent.rb +58 -22
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +9 -0
- data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
- data/scripts/api_snapshot.rb +2 -0
- data/sig/phronomy/agent/execution_contract.rbs +13 -0
- data/sig/phronomy/agent/store.rbs +1 -0
- data/sig/phronomy/agent.rbs +8 -5
- data/sig/phronomy/execution/async_operation.rbs +1 -0
- data/sig/phronomy/generation/generator_verifier.rbs +1 -1
- data/sig/phronomy/multi_agent.rbs +2 -0
- data/sig/phronomy/tools/agent.rbs +14 -0
- data/sig/phronomy/workflow.rbs +1 -0
- metadata +8 -2
|
@@ -6,46 +6,48 @@ module Phronomy
|
|
|
6
6
|
# Session construction and resource selection are supplied by the environment.
|
|
7
7
|
# @api private
|
|
8
8
|
class InvocationActions
|
|
9
|
-
def self.build_entry_actions(
|
|
9
|
+
def self.build_entry_actions(environment, mode:, event_sink:)
|
|
10
|
+
# Generated machine callbacks can outlive a session through VM method
|
|
11
|
+
# caches. Resolve the Agent from the current invocation, never capture it.
|
|
10
12
|
calling_action = if mode.to_sym == :stream
|
|
11
|
-
method(:calling_llm_stream_action).curry.call(
|
|
13
|
+
method(:calling_llm_stream_action).curry.call(environment, event_sink)
|
|
12
14
|
else
|
|
13
|
-
method(:calling_llm_action).curry.call(
|
|
15
|
+
method(:calling_llm_action).curry.call(environment, event_sink)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
{
|
|
17
|
-
filtering_input: [method(:apply_prepared_input_action)
|
|
18
|
-
building_context: [method(:prepare_runtime_input_action)
|
|
19
|
+
filtering_input: [method(:apply_prepared_input_action)],
|
|
20
|
+
building_context: [method(:prepare_runtime_input_action)],
|
|
19
21
|
calling_llm: [calling_action],
|
|
20
22
|
starting_tools: [method(:starting_tools_action).curry.call(environment, event_sink)],
|
|
21
23
|
dispatching_tools: [method(:dispatching_tools_action).curry.call(environment, event_sink)],
|
|
22
24
|
recording_tool_results: [method(:recording_tool_results_action)],
|
|
23
25
|
suspended: [method(:suspended_action)],
|
|
24
|
-
output_filtering: [method(:output_filtering_action)
|
|
26
|
+
output_filtering: [method(:output_filtering_action)],
|
|
25
27
|
failed: [method(:failed_action)]
|
|
26
28
|
}
|
|
27
29
|
end
|
|
28
30
|
|
|
29
|
-
def self.apply_prepared_input_action(
|
|
31
|
+
def self.apply_prepared_input_action(invocation)
|
|
30
32
|
invocation.input = invocation.config.fetch(:phronomy_filtered_input)
|
|
31
33
|
invocation
|
|
32
34
|
end
|
|
33
35
|
private_class_method :apply_prepared_input_action
|
|
34
36
|
|
|
35
|
-
def self.prepare_runtime_input_action(
|
|
37
|
+
def self.prepare_runtime_input_action(invocation)
|
|
36
38
|
projection = invocation.config.fetch(:phronomy_runtime_projection)
|
|
37
|
-
agent.send(:prepare_runtime_input, projection, invocation: invocation)
|
|
39
|
+
invocation.agent.send(:prepare_runtime_input, projection, invocation: invocation)
|
|
38
40
|
end
|
|
39
41
|
private_class_method :prepare_runtime_input_action
|
|
40
42
|
|
|
41
|
-
def self.calling_llm_action(
|
|
42
|
-
prepare_and_start_llm_call(agent, environment, event_sink, invocation, streaming: false)
|
|
43
|
+
def self.calling_llm_action(environment, event_sink, invocation)
|
|
44
|
+
prepare_and_start_llm_call(invocation.agent, environment, event_sink, invocation, streaming: false)
|
|
43
45
|
invocation
|
|
44
46
|
end
|
|
45
47
|
private_class_method :calling_llm_action
|
|
46
48
|
|
|
47
|
-
def self.calling_llm_stream_action(
|
|
48
|
-
prepare_and_start_llm_call(agent, environment, event_sink, invocation, streaming: true)
|
|
49
|
+
def self.calling_llm_stream_action(environment, event_sink, invocation)
|
|
50
|
+
prepare_and_start_llm_call(invocation.agent, environment, event_sink, invocation, streaming: true)
|
|
49
51
|
invocation
|
|
50
52
|
end
|
|
51
53
|
private_class_method :calling_llm_stream_action
|
|
@@ -354,8 +356,8 @@ module Phronomy
|
|
|
354
356
|
end
|
|
355
357
|
private_class_method :failed_action
|
|
356
358
|
|
|
357
|
-
def self.output_filtering_action(
|
|
358
|
-
invocation.output = agent.send(:run_output_filters!, invocation.output)
|
|
359
|
+
def self.output_filtering_action(invocation)
|
|
360
|
+
invocation.output = invocation.agent.send(:run_output_filters!, invocation.output)
|
|
359
361
|
invocation
|
|
360
362
|
rescue Phronomy::FilterBlockError => error
|
|
361
363
|
invocation.output_blocked = true
|
|
@@ -75,6 +75,10 @@ module Phronomy
|
|
|
75
75
|
return @result if outcome.disposition == :committed
|
|
76
76
|
raise error if outcome.disposition == :not_committed
|
|
77
77
|
raise Phronomy::ExecutionRehydrationRequiredError, "Execution change outcome is unknown: #{(outcome.read_error || error).message}"
|
|
78
|
+
ensure
|
|
79
|
+
# A completed synchronous change may remain on a worker's native stack.
|
|
80
|
+
# Release live execution wiring only after commit/readback has finished.
|
|
81
|
+
@agent = @write = @participant = nil
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
private :perform_once
|
|
@@ -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,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
|
|
@@ -9,13 +9,17 @@ module Phronomy
|
|
|
9
9
|
class OutputContract
|
|
10
10
|
FACTORIES = {}
|
|
11
11
|
private_constant :FACTORIES
|
|
12
|
-
attr_reader :schema, :digest
|
|
12
|
+
attr_reader :schema, :digest, :schema_delivery
|
|
13
13
|
|
|
14
14
|
def self.install_factory(factory)
|
|
15
15
|
FACTORIES.replace(parser: factory).freeze
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def initialize(schema)
|
|
18
|
+
def initialize(schema, schema_delivery: :native)
|
|
19
|
+
unless %i[native none].include?(schema_delivery)
|
|
20
|
+
raise ArgumentError, "schema_delivery must be :native or :none"
|
|
21
|
+
end
|
|
22
|
+
@schema_delivery = schema_delivery
|
|
19
23
|
@parser = FACTORIES.fetch(:parser).call(schema)
|
|
20
24
|
@schema = @parser.json_schema
|
|
21
25
|
@digest = Digest::SHA256.hexdigest(JSON.generate(@schema, sort_keys: true)).freeze
|
|
@@ -23,20 +27,58 @@ module Phronomy
|
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def metadata(contents)
|
|
26
|
-
{"output_contract" => {"version" =>
|
|
30
|
+
{"output_contract" => {"version" => 2, "schema_ref" => contents.put_text(JSON.generate(schema, sort_keys: true)),
|
|
31
|
+
"schema_digest" => digest, "schema_delivery" => schema_delivery.to_s}}
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def self.restore(execution, contents)
|
|
30
35
|
stored = execution.metadata["output_contract"]
|
|
31
36
|
return nil unless stored
|
|
32
|
-
|
|
33
|
-
contract = new(contents.fetch_json(stored.fetch("schema_ref")))
|
|
37
|
+
contract = new(contents.fetch_json(stored.fetch("schema_ref")), schema_delivery: delivery(stored))
|
|
34
38
|
unless contract.digest == stored.fetch("schema_digest")
|
|
35
39
|
raise Phronomy::ContentStore::IntegrityError, "Agent output schema digest mismatch"
|
|
36
40
|
end
|
|
37
41
|
contract
|
|
38
42
|
end
|
|
39
43
|
|
|
44
|
+
# The schema stays durable even when the provider receives no schema.
|
|
45
|
+
def self.response_schema_ref(metadata)
|
|
46
|
+
stored = metadata["output_contract"]
|
|
47
|
+
stored.fetch("schema_ref") if stored && delivery(stored) == :native
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.delivery(stored)
|
|
51
|
+
case stored.fetch("version")
|
|
52
|
+
when 1 then :native
|
|
53
|
+
when 2
|
|
54
|
+
value = stored.fetch("schema_delivery")
|
|
55
|
+
unless %w[native none].include?(value)
|
|
56
|
+
raise ArgumentError, "unsupported Agent schema delivery"
|
|
57
|
+
end
|
|
58
|
+
value.to_sym
|
|
59
|
+
else
|
|
60
|
+
raise ArgumentError, "unsupported Agent output contract version"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Agent owns the final-output contract, including bypass prevention when
|
|
65
|
+
# no native schema is sent. Provider configuration cannot replace it.
|
|
66
|
+
def self.validate_model_config!(value)
|
|
67
|
+
case value
|
|
68
|
+
when Hash
|
|
69
|
+
value.each do |key, child|
|
|
70
|
+
if %w[schema schema_delivery output_schema response_schema response_format responseJsonSchema responseSchema
|
|
71
|
+
text.format output_config.format].include?(key.to_s) ||
|
|
72
|
+
(%w[text output_config].include?(key.to_s) && child.is_a?(Hash) && child.keys.any? { |name| name.to_s == "format" })
|
|
73
|
+
raise ArgumentError, "model configuration cannot override the Agent output contract"
|
|
74
|
+
end
|
|
75
|
+
validate_model_config!(child)
|
|
76
|
+
end
|
|
77
|
+
when Array
|
|
78
|
+
value.each { |child| validate_model_config!(child) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
40
82
|
def validate(output, response:, execution_id:)
|
|
41
83
|
raw = response&.content
|
|
42
84
|
reason = if response&.refusal || response&.finish_reason == :refusal
|
|
@@ -12,6 +12,7 @@ module Phronomy
|
|
|
12
12
|
AGENT_ROOT_FORMAT_VERSION = "0.1"
|
|
13
13
|
AGENT_EXECUTION_RECORD_TYPE = "phronomy.agent_execution"
|
|
14
14
|
AGENT_EXECUTION_FORMAT_VERSION = "0.2"
|
|
15
|
+
OUTPUT_CONTRACT_FORMAT_VERSION = "0.3"
|
|
15
16
|
JOURNAL_RECORD_TYPE = "phronomy.journal_record"
|
|
16
17
|
JOURNAL_FORMAT_VERSION = "0.1"
|
|
17
18
|
AGENT_ROOT_KEYS = %w[
|
|
@@ -88,7 +89,7 @@ module Phronomy
|
|
|
88
89
|
validate_agent_execution_payload!(payload)
|
|
89
90
|
build_record(
|
|
90
91
|
AGENT_EXECUTION_RECORD_TYPE,
|
|
91
|
-
|
|
92
|
+
execution_format_version(payload),
|
|
92
93
|
payload
|
|
93
94
|
)
|
|
94
95
|
rescue Phronomy::Storage::SerializationError
|
|
@@ -101,11 +102,15 @@ module Phronomy
|
|
|
101
102
|
payload = current_payload!(
|
|
102
103
|
record,
|
|
103
104
|
record_type: AGENT_EXECUTION_RECORD_TYPE,
|
|
104
|
-
format_version:
|
|
105
|
+
format_version: (record.respond_to?(:format_version) && record.format_version == OUTPUT_CONTRACT_FORMAT_VERSION) ?
|
|
106
|
+
OUTPUT_CONTRACT_FORMAT_VERSION : AGENT_EXECUTION_FORMAT_VERSION,
|
|
105
107
|
keys: AGENT_EXECUTION_KEYS,
|
|
106
108
|
label: "AgentExecution payload"
|
|
107
109
|
)
|
|
108
110
|
validate_agent_execution_payload!(payload)
|
|
111
|
+
unless record.format_version == execution_format_version(payload)
|
|
112
|
+
raise Phronomy::Storage::SerializationError, "Agent output contract does not match execution format version"
|
|
113
|
+
end
|
|
109
114
|
Phronomy::Agent::AgentExecution.from_h(payload)
|
|
110
115
|
rescue Phronomy::Storage::SerializationError
|
|
111
116
|
raise
|
|
@@ -113,6 +118,15 @@ module Phronomy
|
|
|
113
118
|
serialization_error("cannot decode AgentExecution", error)
|
|
114
119
|
end
|
|
115
120
|
|
|
121
|
+
# Old readers reject the new envelope before interpreting any execution.
|
|
122
|
+
# Freeform and version-one records retain their original envelope.
|
|
123
|
+
def execution_format_version(payload)
|
|
124
|
+
stored = payload.fetch("metadata")["output_contract"]
|
|
125
|
+
return AGENT_EXECUTION_FORMAT_VERSION unless stored
|
|
126
|
+
Phronomy::Agent::OutputContract.delivery(stored)
|
|
127
|
+
(stored.fetch("version") == 2) ? OUTPUT_CONTRACT_FORMAT_VERSION : AGENT_EXECUTION_FORMAT_VERSION
|
|
128
|
+
end
|
|
129
|
+
|
|
116
130
|
def encode_journal_record(journal_record)
|
|
117
131
|
payload = journal_payload(journal_record, require_sequence: true)
|
|
118
132
|
build_record(JOURNAL_RECORD_TYPE, JOURNAL_FORMAT_VERSION, payload)
|
|
@@ -28,6 +28,7 @@ module Phronomy
|
|
|
28
28
|
# operation-local projections transferred to the new invocation, never a
|
|
29
29
|
# shared cache or a second source of continuation decisions.
|
|
30
30
|
def prepare_recovery_material(execution, projection: nil)
|
|
31
|
+
OutputContract.restore(execution, agent.persistence.contents)
|
|
31
32
|
action = recovery_action(execution)
|
|
32
33
|
suspended = execution.status == :suspended || execution.phase.to_sym == :resuming
|
|
33
34
|
return if !suspended && %i[resolution_required failed_terminal].include?(action)
|
|
@@ -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
|
|
@@ -9,8 +9,8 @@ module Phronomy
|
|
|
9
9
|
def self.build(invocation:, environment:, event_loop:, resume_event: nil, resume_phase: nil)
|
|
10
10
|
event_sink = Phronomy::FSMSession::EventSink.new(event_loop: event_loop)
|
|
11
11
|
invocation.bind_event_sink!(event_sink)
|
|
12
|
-
actions = InvocationActions.build_entry_actions(
|
|
13
|
-
|
|
12
|
+
actions = InvocationActions.build_entry_actions(environment,
|
|
13
|
+
mode: invocation.mode, event_sink: event_sink)
|
|
14
14
|
phase_machine = PhaseMachineBuilder.new(entry_actions: actions).build
|
|
15
15
|
|
|
16
16
|
Phronomy::FSMSession.new(
|
|
@@ -54,6 +54,13 @@ module Phronomy
|
|
|
54
54
|
)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# One interpretation for execution results and public transcript reads.
|
|
58
|
+
def materialize_transcript(root:, records:)
|
|
59
|
+
materialize_journal_records(
|
|
60
|
+
JournalProjection.new(agent_root: root, records: records).transcript_records
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
57
64
|
def materialize_journal_record(record)
|
|
58
65
|
materialize_segment(segment_from_record(record))
|
|
59
66
|
end
|
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|
|