phronomy 0.23.0 → 0.24.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/.mutant.yml +2 -2
- data/CHANGELOG.md +15 -0
- data/CONTRIBUTING.md +2 -2
- data/README.md +1 -1
- data/docs/architecture/multi-agent-handoff.md +35 -40
- data/docs/architecture/persistence.md +19 -8
- data/docs/architecture.md +8 -1
- data/docs/decisions/016-semantic-multi-agent-handoff.md +3 -1
- data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
- data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
- data/docs/decisions/README.md +5 -1
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
- data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
- data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
- data/docs/features.md +35 -1
- data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +42 -3
- data/lib/phronomy/agent/agent_execution.rb +2 -2
- data/lib/phronomy/agent/agent_invocation.rb +1 -1
- data/lib/phronomy/agent/async_event_api.rb +18 -1
- data/lib/phronomy/agent/base.rb +28 -0
- data/lib/phronomy/agent/context_assembler.rb +1 -1
- data/lib/phronomy/agent/exact_execution.rb +153 -0
- data/lib/phronomy/agent/execution_cancellation.rb +25 -0
- data/lib/phronomy/agent/execution_coordinator.rb +484 -27
- data/lib/phronomy/{multi_agent → agent}/handoff.rb +4 -4
- data/lib/phronomy/{multi_agent → agent}/handoff_capability_factory.rb +3 -45
- data/lib/phronomy/{multi_agent → agent}/handoff_context.rb +26 -1
- data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb} +32 -5
- data/lib/phronomy/{multi_agent → agent}/handoff_policy.rb +7 -1
- data/lib/phronomy/{multi_agent → agent}/handoff_projection.rb +18 -2
- data/lib/phronomy/{multi_agent → agent}/handoff_request.rb +2 -2
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +114 -211
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +84 -130
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +76 -200
- data/lib/phronomy/agent/recovery_coordinator.rb +11 -5
- data/lib/phronomy/agent/recovery_support.rb +15 -23
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -8
- data/lib/phronomy/agent/tool_invocation.rb +4 -2
- data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +16 -1
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +59 -11
- data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
- data/lib/phronomy/multi_agent/team_execution.rb +44 -0
- data/lib/phronomy/multi_agent/team_root.rb +41 -0
- data/lib/phronomy/persistence/durable_codec.rb +60 -0
- data/lib/phronomy/persistence/in_memory.rb +264 -2
- data/lib/phronomy/persistence/repository_facades.rb +221 -2
- data/lib/phronomy/persistence.rb +95 -1
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -0
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
- data/lib/phronomy/testing/persistence_contract.rb +5 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- data/lib/phronomy/version.rb +1 -1
- data/scripts/api_snapshot.rb +3 -3
- data/sig/phronomy/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +28 -32
- data/sig/phronomy/persistence.rbs +64 -3
- metadata +30 -12
- data/lib/phronomy/multi_agent/coordination_state.rb +0 -18
- data/lib/phronomy/multi_agent/coordinator.rb +0 -154
- data/lib/phronomy/multi_agent/runner.rb +0 -98
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "digest"
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
|
-
module
|
|
6
|
+
module Agent
|
|
7
7
|
# Application-defined semantic edge for transferring active responsibility
|
|
8
8
|
# from one live Agent instance to another.
|
|
9
9
|
class Handoff
|
|
@@ -19,14 +19,14 @@ module Phronomy
|
|
|
19
19
|
raise ArgumentError, "Handoff source_agent and target_agent must be different instances"
|
|
20
20
|
end
|
|
21
21
|
unless policy.is_a?(HandoffPolicy)
|
|
22
|
-
raise ArgumentError, "policy must be a Phronomy::
|
|
22
|
+
raise ArgumentError, "policy must be a Phronomy::Agent::HandoffPolicy"
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
@source_agent = source_agent
|
|
26
26
|
@target_agent = target_agent
|
|
27
27
|
@policy = policy
|
|
28
28
|
@description = (description || default_description).to_s.freeze
|
|
29
|
-
@transport_key =
|
|
29
|
+
@transport_key = Digest::SHA256.hexdigest([source_agent.agent_id, target_agent.agent_id].join("\0"))[0, 32].freeze
|
|
30
30
|
freeze
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "weakref"
|
|
4
|
-
|
|
5
3
|
module Phronomy
|
|
6
|
-
module
|
|
4
|
+
module Agent
|
|
7
5
|
class HandoffCapabilityFactory
|
|
8
|
-
REGISTRY_MUTEX = Mutex.new
|
|
9
|
-
private_constant :REGISTRY_MUTEX
|
|
10
|
-
|
|
11
6
|
Binding = Data.define(:handoff, :tool_class, :tool_name) do
|
|
12
7
|
def initialize(handoff:, tool_class:, tool_name:)
|
|
13
8
|
super(handoff: handoff, tool_class: tool_class, tool_name: tool_name.to_s.freeze)
|
|
@@ -17,9 +12,7 @@ module Phronomy
|
|
|
17
12
|
|
|
18
13
|
def self.build(handoff)
|
|
19
14
|
key = handoff.send(:transport_key)
|
|
20
|
-
|
|
21
|
-
target_name = class_slug(handoff.target_agent.class)
|
|
22
|
-
tool_name = "phronomy_handoff_#{source_name}_to_#{target_name}_#{key}"
|
|
15
|
+
tool_name = "phronomy_handoff_#{key}"
|
|
23
16
|
description = handoff.description
|
|
24
17
|
policy = handoff.policy
|
|
25
18
|
|
|
@@ -45,43 +38,8 @@ module Phronomy
|
|
|
45
38
|
end
|
|
46
39
|
end
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
registry_mutex.synchronize { registry[tool_name] = WeakRef.new(binding) }
|
|
50
|
-
binding
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def self.lookup(tool_name)
|
|
54
|
-
key = tool_name.to_s
|
|
55
|
-
registry_mutex.synchronize do
|
|
56
|
-
reference = registry[key]
|
|
57
|
-
return nil unless reference
|
|
58
|
-
|
|
59
|
-
begin
|
|
60
|
-
reference.__getobj__
|
|
61
|
-
rescue WeakRef::RefError
|
|
62
|
-
registry.delete(key)
|
|
63
|
-
nil
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def self.registry
|
|
69
|
-
@registry ||= {}
|
|
70
|
-
end
|
|
71
|
-
private_class_method :registry
|
|
72
|
-
|
|
73
|
-
def self.registry_mutex
|
|
74
|
-
REGISTRY_MUTEX
|
|
75
|
-
end
|
|
76
|
-
private_class_method :registry_mutex
|
|
77
|
-
|
|
78
|
-
def self.class_slug(klass)
|
|
79
|
-
raw = (klass.name || "agent").gsub("::", "_")
|
|
80
|
-
raw.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
|
|
81
|
-
.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
82
|
-
.downcase
|
|
41
|
+
Binding.new(handoff: handoff, tool_class: klass, tool_name: tool_name)
|
|
83
42
|
end
|
|
84
|
-
private_class_method :class_slug
|
|
85
43
|
end
|
|
86
44
|
end
|
|
87
45
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module Agent
|
|
5
5
|
class HandoffContext
|
|
6
6
|
Provenance = Data.define(
|
|
7
7
|
:origin_agent_id,
|
|
@@ -79,6 +79,31 @@ module Phronomy
|
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
def to_h
|
|
83
|
+
{
|
|
84
|
+
"responsibility" => responsibility,
|
|
85
|
+
"items" => items.map do |item|
|
|
86
|
+
item.to_h.transform_keys(&:to_s).merge(
|
|
87
|
+
"candidate_category" => item.candidate_category.to_s,
|
|
88
|
+
"policy_category" => item.policy_category.to_s,
|
|
89
|
+
"role" => item.role&.to_s,
|
|
90
|
+
"content_format" => item.content_format.to_s,
|
|
91
|
+
"provenance" => item.provenance.to_h
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
}.freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.from_h(value)
|
|
98
|
+
source = value.transform_keys(&:to_s)
|
|
99
|
+
raise ArgumentError, "HandoffContext schema mismatch" unless source.keys.sort == %w[items responsibility]
|
|
100
|
+
new(responsibility: source.fetch("responsibility"), items: source.fetch("items").map do |raw|
|
|
101
|
+
values = raw.transform_keys(&:to_sym)
|
|
102
|
+
values[:provenance] = Provenance.new(**values.fetch(:provenance).transform_keys(&:to_sym))
|
|
103
|
+
Item.new(**values)
|
|
104
|
+
end)
|
|
105
|
+
end
|
|
106
|
+
|
|
82
107
|
attr_reader :responsibility, :items
|
|
83
108
|
|
|
84
109
|
def initialize(responsibility:, items: [])
|
data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb}
RENAMED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module Phronomy
|
|
4
|
-
module
|
|
6
|
+
module Agent
|
|
5
7
|
# Handoff-aware specialization of the normal Agent execution coordinator.
|
|
6
8
|
# It changes only the durable terminal semantics of an invocation that
|
|
7
9
|
# produced a typed HandoffRequest. EventLoop ownership/apply remains entirely
|
|
8
10
|
# in Agent::ExecutionCoordinator.
|
|
9
|
-
class
|
|
11
|
+
class HandoffExecutionCoordinator < Phronomy::Agent::ExecutionCoordinator
|
|
10
12
|
private
|
|
11
13
|
|
|
12
14
|
def compute_terminal(operation)
|
|
@@ -17,7 +19,9 @@ module Phronomy
|
|
|
17
19
|
view.callback_failure.to_stream_callback_error
|
|
18
20
|
)
|
|
19
21
|
end
|
|
20
|
-
|
|
22
|
+
error = view.source_error || view.block_error || view.invocation_error
|
|
23
|
+
raise error if error.is_a?(Phronomy::ExecutionRehydrationRequiredError)
|
|
24
|
+
return commit_failed_outcome(operation, error) if error
|
|
21
25
|
return commit_suspended(operation) if view.phase == :suspended
|
|
22
26
|
|
|
23
27
|
raise view.block_error if view.input_blocked || view.output_blocked
|
|
@@ -27,7 +31,7 @@ module Phronomy
|
|
|
27
31
|
|
|
28
32
|
commit_completed(operation)
|
|
29
33
|
rescue => caught
|
|
30
|
-
|
|
34
|
+
reconcile_terminal_error(operation, caught)
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def commit_handed_off(operation)
|
|
@@ -38,6 +42,27 @@ module Phronomy
|
|
|
38
42
|
handed_off = next_root = appended = nil
|
|
39
43
|
|
|
40
44
|
@agent.persistence.transaction do |tx|
|
|
45
|
+
coordination = current.metadata.fetch("coordination")
|
|
46
|
+
main_id = coordination.fetch("main_agent_id")
|
|
47
|
+
routing = tx.handoff_states.load(main_id)
|
|
48
|
+
unless routing && routing.active_agent_id == @agent.agent_id && routing.handoff_revision == coordination.fetch("handoff_revision")
|
|
49
|
+
raise Phronomy::Persistence::ConflictError, "Handoff routing changed before Source transfer"
|
|
50
|
+
end
|
|
51
|
+
if Array(routing.metadata["cancelled_execution_ids"]).include?(current.execution_id)
|
|
52
|
+
raise Phronomy::CancellationError, "Handoff Source turn was cancelled"
|
|
53
|
+
end
|
|
54
|
+
manifest = RecoverySupport.manifest_from_ref(@agent, current.metadata.fetch("manifest_ref"))
|
|
55
|
+
context = HandoffProjection.new.build_terminal(view: request, manifest: manifest,
|
|
56
|
+
persistence: tx, source_agent_id: @agent.agent_id)
|
|
57
|
+
context_ref = tx.contents.put_json(context.to_h)
|
|
58
|
+
target_id = "handoff-target-#{Digest::SHA256.hexdigest([current.execution_id, request.target_agent_id].join("\0"))}"
|
|
59
|
+
target_root = tx.agents.load(request.target_agent_id)
|
|
60
|
+
target_definition = {"id" => target_root.agent_definition_id, "version" => target_root.agent_definition_version}
|
|
61
|
+
transfer = routing.with(active_agent_id: request.target_agent_id,
|
|
62
|
+
active_handoff_context_ref: context_ref, phase: "target_pending",
|
|
63
|
+
pending_source_execution_id: current.execution_id, pending_target_execution_id: target_id,
|
|
64
|
+
metadata: routing.metadata.merge("target_definition" => target_definition))
|
|
65
|
+
tx.handoff_states.save(main_id, expected_revision: routing.handoff_revision, state: transfer)
|
|
41
66
|
encoded_records, call_records = encode_runtime_records(
|
|
42
67
|
current,
|
|
43
68
|
tx: tx,
|
|
@@ -79,7 +104,9 @@ module Phronomy
|
|
|
79
104
|
working_records: [],
|
|
80
105
|
llm_calls: current.llm_calls + call_records,
|
|
81
106
|
approval_request: nil,
|
|
82
|
-
terminal_reason: "handed_off"
|
|
107
|
+
terminal_reason: "handed_off",
|
|
108
|
+
metadata: current.metadata.merge("handoff_target_agent_id" => request.target_agent_id,
|
|
109
|
+
"handoff_target_execution_id" => target_id, "handoff_context_ref" => context_ref)
|
|
83
110
|
)
|
|
84
111
|
tx.executions.save(
|
|
85
112
|
current.execution_id,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module Agent
|
|
5
5
|
class HandoffPolicy
|
|
6
6
|
CATEGORIES = %i[current_request history knowledge tool_exchanges].freeze
|
|
7
7
|
MODES = %i[required forbidden selectable].freeze
|
|
@@ -77,6 +77,12 @@ module Phronomy
|
|
|
77
77
|
builder.build
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Reconstructs a value-only operation snapshot, not Application code.
|
|
81
|
+
# @api private
|
|
82
|
+
def self.from_h(value)
|
|
83
|
+
new(value.map { |category, rule| Rule.new(category: category, **rule.transform_keys(&:to_sym)) })
|
|
84
|
+
end
|
|
85
|
+
|
|
80
86
|
def self.default
|
|
81
87
|
@default ||= define do
|
|
82
88
|
required :current_request
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module Agent
|
|
5
5
|
class HandoffProjection
|
|
6
6
|
CONTROL_CATEGORIES = %i[instruction handoff_responsibility].freeze
|
|
7
7
|
JSON_CATEGORIES = %i[assistant_message tool_message].freeze
|
|
8
8
|
|
|
9
|
+
Identity = Data.define(:agent_id)
|
|
10
|
+
Edge = Data.define(:policy, :target_agent)
|
|
11
|
+
Snapshot = Data.define(:handoff, :responsibility, :selection_intent)
|
|
12
|
+
private_constant :Identity, :Edge, :Snapshot
|
|
13
|
+
|
|
14
|
+
# A terminal command contains values only, captured on EventLoop.
|
|
15
|
+
def build_terminal(view:, manifest:, persistence:, source_agent_id:)
|
|
16
|
+
request = Snapshot.new(
|
|
17
|
+
handoff: Edge.new(policy: HandoffPolicy.from_h(view.policy), target_agent: Identity.new(agent_id: view.target_agent_id)),
|
|
18
|
+
responsibility: view.responsibility,
|
|
19
|
+
selection_intent: view.selection_intent.transform_keys(&:to_sym)
|
|
20
|
+
)
|
|
21
|
+
build(request: request, manifest: manifest, persistence: persistence,
|
|
22
|
+
source_agent: Identity.new(agent_id: source_agent_id))
|
|
23
|
+
end
|
|
24
|
+
|
|
9
25
|
def build(request:, manifest:, persistence:, source_agent:)
|
|
10
|
-
unless request.is_a?(HandoffRequest)
|
|
26
|
+
unless request.is_a?(HandoffRequest) || request.is_a?(Snapshot)
|
|
11
27
|
raise ArgumentError, "request must be a HandoffRequest"
|
|
12
28
|
end
|
|
13
29
|
unless manifest.is_a?(Phronomy::Agent::LLMInputManifest)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module Agent
|
|
5
5
|
# Typed private control request produced from one intercepted Handoff capability.
|
|
6
6
|
# @api private
|
|
7
7
|
HandoffRequest = Data.define(
|
|
@@ -11,7 +11,7 @@ module Phronomy
|
|
|
11
11
|
responsibility = responsibility.to_s.strip
|
|
12
12
|
raise ArgumentError, "Handoff responsibility must not be empty" if responsibility.empty?
|
|
13
13
|
unless handoff.is_a?(Handoff)
|
|
14
|
-
raise ArgumentError, "handoff must be a Phronomy::
|
|
14
|
+
raise ArgumentError, "handoff must be a Phronomy::Agent::Handoff"
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
normalized = handoff.policy.selectable_categories.to_h do |category|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
# Durable Agent-domain responsibility routing within one Persistence instance.
|
|
8
|
+
# @api public
|
|
9
|
+
class HandoffRunner
|
|
10
|
+
MAX_HANDOFFS = 20
|
|
11
|
+
|
|
12
|
+
# @api public
|
|
13
|
+
attr_reader :main_agent, :handoffs
|
|
14
|
+
|
|
15
|
+
# @api public
|
|
16
|
+
def initialize(main_agent:, handoffs: [])
|
|
17
|
+
@main_agent, @handoffs = main_agent, Array(handoffs).freeze
|
|
18
|
+
@persistence = main_agent.persistence
|
|
19
|
+
raise ArgumentError, "handoffs must contain Agent::Handoff" unless @handoffs.all? { |h| h.is_a?(Handoff) }
|
|
20
|
+
@agents = ([main_agent] + @handoffs.flat_map { |edge| [edge.source_agent, edge.target_agent] }).uniq.to_h { |a| [a.agent_id, a] }.freeze
|
|
21
|
+
unless @agents.values.all? { |a| a.persistence.equal?(@persistence) }
|
|
22
|
+
raise Phronomy::ConfigurationError, "Durable Handoff graph requires one Persistence instance"
|
|
23
|
+
end
|
|
24
|
+
if @handoffs.group_by { |h| [h.source_agent.agent_id, h.target_agent.agent_id] }.any? { |_, edges| edges.size > 1 }
|
|
25
|
+
raise ArgumentError, "Duplicate Source/Target Handoff edges"
|
|
26
|
+
end
|
|
27
|
+
@bindings = @handoffs.group_by { |h| h.source_agent.agent_id }.transform_values { |edges| edges.map { |h| HandoffCapabilityFactory.build(h) }.freeze }.freeze
|
|
28
|
+
@runtime = Phronomy::Runtime.instance
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @api public
|
|
32
|
+
def invoke(input, config: {})
|
|
33
|
+
trace_handle = Phronomy::Tracing::Automatic.start("multi_agent.turn",
|
|
34
|
+
input: input, main_agent_id: main_agent.agent_id,
|
|
35
|
+
**main_agent.send(:_build_caller_meta, config))
|
|
36
|
+
operation_error = result = nil
|
|
37
|
+
if Phronomy::Runtime.in_event_loop_context?
|
|
38
|
+
raise Phronomy::EventLoopReentrancyError, "HandoffRunner#invoke cannot block EventLoop"
|
|
39
|
+
end
|
|
40
|
+
unless Phronomy::Runtime.instance.equal?(@runtime)
|
|
41
|
+
raise Phronomy::RuntimeShutdownError, "HandoffRunner belongs to a previous Runtime"
|
|
42
|
+
end
|
|
43
|
+
config = config.merge(cancellation_token: config[:cancellation_token] || Phronomy::Concurrency::CancellationToken.new)
|
|
44
|
+
@runtime.__admit_multi_agent(main_agent)
|
|
45
|
+
admitted = true
|
|
46
|
+
state = load_state
|
|
47
|
+
count = 0
|
|
48
|
+
loop do
|
|
49
|
+
active = @agents.fetch(state.active_agent_id) do
|
|
50
|
+
raise Phronomy::ExecutionRehydrationRequiredError, "Handoff graph lacks active Agent #{state.active_agent_id}"
|
|
51
|
+
end
|
|
52
|
+
context = state.active_handoff_context_ref && HandoffContext.from_h(@persistence.contents.fetch_json(state.active_handoff_context_ref))
|
|
53
|
+
wiring = config.merge(phronomy_handoff_bindings: @bindings.fetch(active.agent_id, []),
|
|
54
|
+
phronomy_handoff_context: context,
|
|
55
|
+
phronomy_coordination: {"kind" => "handoff", "main_agent_id" => main_agent.agent_id, "handoff_revision" => state.handoff_revision}).freeze
|
|
56
|
+
active.instance_variable_set(:@_phronomy_coordination_config, wiring)
|
|
57
|
+
result = if state.phase == "stable"
|
|
58
|
+
unfinished = @persistence.executions.list_active(active.agent_id)
|
|
59
|
+
if unfinished.empty?
|
|
60
|
+
active.invoke(input, config: wiring)
|
|
61
|
+
else
|
|
62
|
+
exact = unfinished.fetch(0)
|
|
63
|
+
unless unfinished.size == 1 && exact.metadata.dig("coordination", "main_agent_id") == main_agent.agent_id
|
|
64
|
+
raise Phronomy::Persistence::ConflictError, "Active Agent execution belongs to another coordination turn"
|
|
65
|
+
end
|
|
66
|
+
wiring[:cancellation_token].cancel! if Array(state.metadata["cancelled_execution_ids"]).include?(exact.execution_id)
|
|
67
|
+
stored_input = @persistence.contents.fetch_text(exact.metadata.fetch("current_input_ref"))
|
|
68
|
+
ExactExecution.start(agent: active, execution_id: exact.execution_id, input: stored_input, config: wiring).wait_result
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
source = @persistence.executions.load(state.pending_source_execution_id)
|
|
72
|
+
unless @handoffs.any? { |edge| edge.source_agent.agent_id == source.agent_id && edge.target_agent.agent_id == state.active_agent_id }
|
|
73
|
+
raise Phronomy::ExecutionRehydrationRequiredError, "Handoff graph lacks committed Source/Target edge"
|
|
74
|
+
end
|
|
75
|
+
definition = state.metadata.fetch("target_definition")
|
|
76
|
+
unless active.class.agent_definition == definition.transform_keys(&:to_sym)
|
|
77
|
+
raise Phronomy::ConfigurationError, "Handoff Target definition mismatch"
|
|
78
|
+
end
|
|
79
|
+
wiring[:cancellation_token].cancel! if Array(state.metadata["cancelled_execution_ids"]).include?(state.pending_target_execution_id)
|
|
80
|
+
ExactExecution.start(agent: active, execution_id: state.pending_target_execution_id,
|
|
81
|
+
input: context.responsibility, config: wiring).wait_result
|
|
82
|
+
end
|
|
83
|
+
execution = @persistence.executions.load(result.fetch(:execution_id))
|
|
84
|
+
if execution.status == :handed_off
|
|
85
|
+
count += 1
|
|
86
|
+
raise Phronomy::HandoffError, "Exceeded maximum Handoffs in one turn" if count > MAX_HANDOFFS
|
|
87
|
+
state = @persistence.handoff_states.load(main_agent.agent_id)
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
if execution.active?
|
|
91
|
+
raise Phronomy::ExecutionRehydrationRequiredError, "Handoff execution requires approval or recovery"
|
|
92
|
+
end
|
|
93
|
+
raise RecoverySupport.error_from_failure(result[:error]) if result[:error]
|
|
94
|
+
return result.reject { |key, _| key.to_s.start_with?("_phronomy_") || key == :handoff_request }.merge(agent: active)
|
|
95
|
+
end
|
|
96
|
+
rescue Phronomy::CancellationError => error
|
|
97
|
+
operation_error = error
|
|
98
|
+
cancel(state.pending_source_execution_id) if state && state.phase != "stable"
|
|
99
|
+
raise
|
|
100
|
+
rescue => error
|
|
101
|
+
operation_error = error
|
|
102
|
+
raise
|
|
103
|
+
ensure
|
|
104
|
+
@runtime.__release_multi_agent(main_agent) if admitted
|
|
105
|
+
Phronomy::Tracing::Automatic.finish(trace_handle, output: result && result[:output], error: operation_error)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Durably scopes cancellation to an exact turn, including a transferred
|
|
109
|
+
# Target reservation. A completed older turn never cancels a later turn.
|
|
110
|
+
# @api public
|
|
111
|
+
def cancel(execution_id)
|
|
112
|
+
if Phronomy::Runtime.in_event_loop_context?
|
|
113
|
+
raise Phronomy::EventLoopReentrancyError, "HandoffRunner#cancel cannot block EventLoop"
|
|
114
|
+
end
|
|
115
|
+
intended = leaf = leaf_id = nil
|
|
116
|
+
begin
|
|
117
|
+
@persistence.transaction do |tx|
|
|
118
|
+
first = tx.executions.load(execution_id)
|
|
119
|
+
unless first.metadata.dig("coordination", "main_agent_id") == main_agent.agent_id
|
|
120
|
+
raise Phronomy::Persistence::ConflictError, "Execution does not belong to this Handoff anchor"
|
|
121
|
+
end
|
|
122
|
+
leaf = first
|
|
123
|
+
leaf_id = first.execution_id
|
|
124
|
+
seen = {}
|
|
125
|
+
while leaf&.status == :handed_off
|
|
126
|
+
raise Phronomy::Persistence::SerializationError, "Cyclic Handoff chain" if seen[leaf_id]
|
|
127
|
+
seen[leaf_id] = true
|
|
128
|
+
leaf_id = leaf.metadata.fetch("handoff_target_execution_id")
|
|
129
|
+
begin
|
|
130
|
+
leaf = tx.executions.load(leaf_id)
|
|
131
|
+
rescue Phronomy::Persistence::NotFoundError
|
|
132
|
+
leaf = nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
next if leaf&.terminal?
|
|
136
|
+
routing = tx.handoff_states.load(main_agent.agent_id)
|
|
137
|
+
unless routing && (leaf ? routing.active_agent_id == leaf.agent_id : routing.pending_target_execution_id == leaf_id)
|
|
138
|
+
raise Phronomy::Persistence::ConflictError, "Handoff routing no longer owns the requested turn"
|
|
139
|
+
end
|
|
140
|
+
ids = (Array(routing.metadata["cancelled_execution_ids"]) + [leaf_id]).uniq
|
|
141
|
+
intended = routing.with(phase: leaf ? routing.phase : "stable",
|
|
142
|
+
metadata: routing.metadata.merge("cancelled_execution_ids" => ids))
|
|
143
|
+
tx.handoff_states.save(main_agent.agent_id, expected_revision: routing.handoff_revision, state: intended)
|
|
144
|
+
end
|
|
145
|
+
rescue => error
|
|
146
|
+
confirmed = @persistence.handoff_states.load(main_agent.agent_id)
|
|
147
|
+
raise error unless intended && confirmed && Array(confirmed.metadata["cancelled_execution_ids"]).include?(leaf_id)
|
|
148
|
+
end
|
|
149
|
+
return @persistence.execution_result(leaf_id) if leaf&.terminal?
|
|
150
|
+
ExecutionCancellation.signal(leaf_id, leaf.agent_id) if leaf&.active?
|
|
151
|
+
{execution_id: leaf_id, cancellation_requested: !leaf&.terminal?}.freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Reads a specified source/Target chain without current graph continuation.
|
|
155
|
+
# @api public
|
|
156
|
+
def result(execution_id)
|
|
157
|
+
@persistence.handoff_result(execution_id, main_agent_id: main_agent.agent_id)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def load_state
|
|
163
|
+
state = @persistence.handoff_states.load(main_agent.agent_id)
|
|
164
|
+
return state if state
|
|
165
|
+
now = Time.now.utc.iso8601(6)
|
|
166
|
+
initial = HandoffState.new(main_agent_id: main_agent.agent_id, handoff_revision: 1,
|
|
167
|
+
active_agent_id: main_agent.agent_id, active_handoff_context_ref: nil,
|
|
168
|
+
phase: "stable", pending_source_execution_id: nil, pending_target_execution_id: nil,
|
|
169
|
+
created_at: now, updated_at: now, metadata: {})
|
|
170
|
+
@persistence.transaction { |tx| tx.handoff_states.save(main_agent.agent_id, expected_revision: nil, state: initial) }
|
|
171
|
+
rescue => error
|
|
172
|
+
confirmed = @persistence.handoff_states.load(main_agent.agent_id)
|
|
173
|
+
raise error unless confirmed
|
|
174
|
+
confirmed
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
# Immutable current-format semantic record; contains no Runtime handles.
|
|
8
|
+
# @api private
|
|
9
|
+
class HandoffState
|
|
10
|
+
ATTRIBUTES = %w[main_agent_id handoff_revision active_agent_id active_handoff_context_ref phase pending_source_execution_id pending_target_execution_id created_at updated_at metadata].freeze
|
|
11
|
+
attr_reader(*ATTRIBUTES)
|
|
12
|
+
|
|
13
|
+
def initialize(**values)
|
|
14
|
+
source = values.transform_keys(&:to_s)
|
|
15
|
+
raise ArgumentError, "HandoffState schema mismatch" unless source.keys.sort == ATTRIBUTES.sort
|
|
16
|
+
canonical = Phronomy::CanonicalJSON.load(Phronomy::CanonicalJSON.dump(source))
|
|
17
|
+
ATTRIBUTES.each { |key| instance_variable_set("@#{key}", Phronomy::Agent::Immutable.copy(canonical.fetch(key))) }
|
|
18
|
+
raise ArgumentError, "missing main_agent_id" if main_agent_id.to_s.empty?
|
|
19
|
+
raise ArgumentError, "invalid handoff_revision" unless handoff_revision.is_a?(Integer) && handoff_revision >= 0
|
|
20
|
+
raise ArgumentError, "invalid metadata" unless metadata.is_a?(Hash)
|
|
21
|
+
raise ArgumentError, "invalid Handoff phase" unless %w[stable target_pending target_active].include?(phase)
|
|
22
|
+
raise ArgumentError, "missing active Agent" if active_agent_id.to_s.empty?
|
|
23
|
+
if phase != "stable" && pending_target_execution_id.to_s.empty?
|
|
24
|
+
raise ArgumentError, "pending Handoff requires exact Target execution"
|
|
25
|
+
end
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h = ATTRIBUTES.to_h { |key| [key, public_send(key)] }.freeze
|
|
30
|
+
|
|
31
|
+
def self.from_h(value)
|
|
32
|
+
new(**value.transform_keys(&:to_sym))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def with(**changes)
|
|
36
|
+
values = to_h.merge(changes.transform_keys(&:to_s))
|
|
37
|
+
values["handoff_revision"] = handoff_revision + 1 unless changes.key?(:handoff_revision)
|
|
38
|
+
values["updated_at"] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
|
|
39
|
+
self.class.from_h(values)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|