phronomy 0.22.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 +3 -4
- data/CHANGELOG.md +215 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +147 -0
- data/docs/architecture/persistence.md +186 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +89 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- 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 +176 -0
- 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 +65 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +129 -40
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +21 -17
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +162 -72
- data/lib/phronomy/agent/base.rb +416 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- 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 +2417 -572
- data/lib/phronomy/agent/handoff.rb +43 -0
- data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
- data/lib/phronomy/agent/handoff_context.rb +120 -0
- data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
- data/lib/phronomy/agent/handoff_policy.rb +143 -0
- data/lib/phronomy/agent/handoff_projection.rb +207 -0
- data/lib/phronomy/agent/handoff_request.rb +45 -0
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
- data/lib/phronomy/agent/recovery_support.rb +504 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +339 -103
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +92 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
- 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 +706 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +471 -133
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +535 -0
- data/lib/phronomy/persistence.rb +174 -40
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- 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/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +35 -0
- data/sig/phronomy/persistence.rbs +123 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +84 -18
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- data/lib/phronomy/agent/runner.rb +0 -97
- data/lib/phronomy/multi_agent/handoff.rb +0 -64
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
# Application-defined semantic edge for transferring active responsibility
|
|
8
|
+
# from one live Agent instance to another.
|
|
9
|
+
class Handoff
|
|
10
|
+
attr_reader :source_agent, :target_agent, :policy, :description
|
|
11
|
+
|
|
12
|
+
# @api public
|
|
13
|
+
def initialize(source_agent:, target_agent:, policy: HandoffPolicy.default, description: nil)
|
|
14
|
+
unless source_agent.is_a?(Phronomy::Agent::Base) &&
|
|
15
|
+
target_agent.is_a?(Phronomy::Agent::Base)
|
|
16
|
+
raise ArgumentError, "source_agent and target_agent must be Agent::Base instances"
|
|
17
|
+
end
|
|
18
|
+
if source_agent.equal?(target_agent)
|
|
19
|
+
raise ArgumentError, "Handoff source_agent and target_agent must be different instances"
|
|
20
|
+
end
|
|
21
|
+
unless policy.is_a?(HandoffPolicy)
|
|
22
|
+
raise ArgumentError, "policy must be a Phronomy::Agent::HandoffPolicy"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@source_agent = source_agent
|
|
26
|
+
@target_agent = target_agent
|
|
27
|
+
@policy = policy
|
|
28
|
+
@description = (description || default_description).to_s.freeze
|
|
29
|
+
@transport_key = Digest::SHA256.hexdigest([source_agent.agent_id, target_agent.agent_id].join("\0"))[0, 32].freeze
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :transport_key
|
|
36
|
+
|
|
37
|
+
def default_description
|
|
38
|
+
target_name = target_agent.class.name || "target Agent"
|
|
39
|
+
"Transfer active responsibility to #{target_name}."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class HandoffCapabilityFactory
|
|
6
|
+
Binding = Data.define(:handoff, :tool_class, :tool_name) do
|
|
7
|
+
def initialize(handoff:, tool_class:, tool_name:)
|
|
8
|
+
super(handoff: handoff, tool_class: tool_class, tool_name: tool_name.to_s.freeze)
|
|
9
|
+
freeze
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.build(handoff)
|
|
14
|
+
key = handoff.send(:transport_key)
|
|
15
|
+
tool_name = "phronomy_handoff_#{key}"
|
|
16
|
+
description = handoff.description
|
|
17
|
+
policy = handoff.policy
|
|
18
|
+
|
|
19
|
+
klass = Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
20
|
+
self.tool_name(tool_name)
|
|
21
|
+
self.description(description)
|
|
22
|
+
execution_mode :cooperative
|
|
23
|
+
param :responsibility,
|
|
24
|
+
type: :string,
|
|
25
|
+
required: true,
|
|
26
|
+
desc: "The concrete responsibility the target Agent must continue."
|
|
27
|
+
|
|
28
|
+
policy.selectable_categories.each do |category|
|
|
29
|
+
param :"include_#{category}",
|
|
30
|
+
type: :boolean,
|
|
31
|
+
required: false,
|
|
32
|
+
desc: "Whether to transfer selectable #{category} Context."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
define_method(:execute) do |**_args|
|
|
36
|
+
raise Phronomy::HandoffError,
|
|
37
|
+
"Handoff capabilities are control-plane operations and must not execute as Tools"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Binding.new(handoff: handoff, tool_class: klass, tool_name: tool_name)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class HandoffContext
|
|
6
|
+
Provenance = Data.define(
|
|
7
|
+
:origin_agent_id,
|
|
8
|
+
:origin_record_id,
|
|
9
|
+
:origin_execution_id,
|
|
10
|
+
:origin_llm_call_id,
|
|
11
|
+
:origin_tool_call_id,
|
|
12
|
+
:transfer_path
|
|
13
|
+
) do
|
|
14
|
+
def initialize(**values)
|
|
15
|
+
super(**values.merge(
|
|
16
|
+
origin_agent_id: values[:origin_agent_id]&.to_s&.freeze,
|
|
17
|
+
origin_record_id: values[:origin_record_id]&.to_s&.freeze,
|
|
18
|
+
origin_execution_id: values[:origin_execution_id]&.to_s&.freeze,
|
|
19
|
+
origin_llm_call_id: values[:origin_llm_call_id]&.to_s&.freeze,
|
|
20
|
+
origin_tool_call_id: values[:origin_tool_call_id]&.to_s&.freeze,
|
|
21
|
+
transfer_path: Array(values[:transfer_path]).map(&:to_s).freeze
|
|
22
|
+
))
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def forwarded_to(agent_id)
|
|
27
|
+
self.class.new(
|
|
28
|
+
origin_agent_id: origin_agent_id,
|
|
29
|
+
origin_record_id: origin_record_id,
|
|
30
|
+
origin_execution_id: origin_execution_id,
|
|
31
|
+
origin_llm_call_id: origin_llm_call_id,
|
|
32
|
+
origin_tool_call_id: origin_tool_call_id,
|
|
33
|
+
transfer_path: transfer_path + [agent_id.to_s]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
{
|
|
39
|
+
"origin_agent_id" => origin_agent_id,
|
|
40
|
+
"origin_record_id" => origin_record_id,
|
|
41
|
+
"origin_execution_id" => origin_execution_id,
|
|
42
|
+
"origin_llm_call_id" => origin_llm_call_id,
|
|
43
|
+
"origin_tool_call_id" => origin_tool_call_id,
|
|
44
|
+
"transfer_path" => transfer_path
|
|
45
|
+
}.compact.freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Item = Data.define(
|
|
50
|
+
:candidate_category,
|
|
51
|
+
:policy_category,
|
|
52
|
+
:role,
|
|
53
|
+
:content,
|
|
54
|
+
:content_format,
|
|
55
|
+
:tool_call_id,
|
|
56
|
+
:provenance,
|
|
57
|
+
:metadata
|
|
58
|
+
) do
|
|
59
|
+
def initialize(**values)
|
|
60
|
+
provenance = values.fetch(:provenance)
|
|
61
|
+
unless provenance.is_a?(Provenance)
|
|
62
|
+
raise ArgumentError, "HandoffContext::Item provenance must be Provenance"
|
|
63
|
+
end
|
|
64
|
+
format = (values[:content_format] || :text).to_sym
|
|
65
|
+
unless %i[text json].include?(format)
|
|
66
|
+
raise ArgumentError, "unsupported Handoff Context content format: #{format.inspect}"
|
|
67
|
+
end
|
|
68
|
+
super(**values.merge(
|
|
69
|
+
candidate_category: values.fetch(:candidate_category).to_sym,
|
|
70
|
+
policy_category: values.fetch(:policy_category).to_sym,
|
|
71
|
+
role: values[:role]&.to_sym,
|
|
72
|
+
content: Phronomy::Agent::Immutable.copy(values.fetch(:content)),
|
|
73
|
+
content_format: format,
|
|
74
|
+
tool_call_id: values[:tool_call_id]&.to_s&.freeze,
|
|
75
|
+
provenance: provenance,
|
|
76
|
+
metadata: Phronomy::Agent::Immutable.copy(values[:metadata] || {})
|
|
77
|
+
))
|
|
78
|
+
freeze
|
|
79
|
+
end
|
|
80
|
+
end
|
|
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
|
+
|
|
107
|
+
attr_reader :responsibility, :items
|
|
108
|
+
|
|
109
|
+
def initialize(responsibility:, items: [])
|
|
110
|
+
@responsibility = responsibility.to_s.freeze
|
|
111
|
+
@items = Array(items).freeze
|
|
112
|
+
raise ArgumentError, "Handoff Context responsibility must not be empty" if @responsibility.strip.empty?
|
|
113
|
+
unless @items.all? { |item| item.is_a?(Item) }
|
|
114
|
+
raise ArgumentError, "Handoff Context items must be HandoffContext::Item values"
|
|
115
|
+
end
|
|
116
|
+
freeze
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
# Handoff-aware specialization of the normal Agent execution coordinator.
|
|
8
|
+
# It changes only the durable terminal semantics of an invocation that
|
|
9
|
+
# produced a typed HandoffRequest. EventLoop ownership/apply remains entirely
|
|
10
|
+
# in Agent::ExecutionCoordinator.
|
|
11
|
+
class HandoffExecutionCoordinator < Phronomy::Agent::ExecutionCoordinator
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def compute_terminal(operation)
|
|
15
|
+
view = operation.terminal_view
|
|
16
|
+
if view.callback_failure
|
|
17
|
+
return commit_failed_outcome(
|
|
18
|
+
operation,
|
|
19
|
+
view.callback_failure.to_stream_callback_error
|
|
20
|
+
)
|
|
21
|
+
end
|
|
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
|
|
25
|
+
return commit_suspended(operation) if view.phase == :suspended
|
|
26
|
+
|
|
27
|
+
raise view.block_error if view.input_blocked || view.output_blocked
|
|
28
|
+
raise view.invocation_error if view.invocation_error
|
|
29
|
+
|
|
30
|
+
return commit_handed_off(operation) if view.handoff
|
|
31
|
+
|
|
32
|
+
commit_completed(operation)
|
|
33
|
+
rescue => caught
|
|
34
|
+
reconcile_terminal_error(operation, caught)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def commit_handed_off(operation)
|
|
38
|
+
current = operation.execution
|
|
39
|
+
root = operation.root
|
|
40
|
+
runtime_snapshot = operation.runtime_snapshot
|
|
41
|
+
request = operation.terminal_view.handoff
|
|
42
|
+
handed_off = next_root = appended = nil
|
|
43
|
+
|
|
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)
|
|
66
|
+
encoded_records, call_records = encode_runtime_records(
|
|
67
|
+
current,
|
|
68
|
+
tx: tx,
|
|
69
|
+
snapshot: runtime_snapshot,
|
|
70
|
+
context_candidate: false,
|
|
71
|
+
agent_root: root
|
|
72
|
+
)
|
|
73
|
+
audit_ref = tx.contents.put_json(
|
|
74
|
+
"target_agent_id" => request.target_agent_id,
|
|
75
|
+
"responsibility" => request.responsibility,
|
|
76
|
+
"selection_intent" => request.selection_intent.to_h do |category, included|
|
|
77
|
+
[category.to_s, included]
|
|
78
|
+
end
|
|
79
|
+
)
|
|
80
|
+
audit_record = Phronomy::Agent::JournalRecord.new(
|
|
81
|
+
agent_id: @agent.agent_id,
|
|
82
|
+
execution_id: current.execution_id,
|
|
83
|
+
llm_call_id: request.llm_call_id,
|
|
84
|
+
kind: :execution_handed_off,
|
|
85
|
+
channel: :audit,
|
|
86
|
+
content_ref: audit_ref,
|
|
87
|
+
context_generation: root.transcript_generation,
|
|
88
|
+
context_candidate: false,
|
|
89
|
+
metadata: {
|
|
90
|
+
"target_agent_id" => request.target_agent_id,
|
|
91
|
+
"handoff_tool_call_id" => request.tool_call_id
|
|
92
|
+
}.compact
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
all_records = current.working_records + encoded_records + [audit_record]
|
|
96
|
+
appended = tx.journals.append(
|
|
97
|
+
root.agent_id,
|
|
98
|
+
expected_position: root.journal_position,
|
|
99
|
+
records: all_records
|
|
100
|
+
)
|
|
101
|
+
handed_off = current.with(
|
|
102
|
+
status: :handed_off,
|
|
103
|
+
phase: :handed_off,
|
|
104
|
+
working_records: [],
|
|
105
|
+
llm_calls: current.llm_calls + call_records,
|
|
106
|
+
approval_request: nil,
|
|
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)
|
|
110
|
+
)
|
|
111
|
+
tx.executions.save(
|
|
112
|
+
current.execution_id,
|
|
113
|
+
expected_revision: current.execution_revision,
|
|
114
|
+
execution: handed_off
|
|
115
|
+
)
|
|
116
|
+
context_changed = appended.any?(&:context_candidate)
|
|
117
|
+
next_root = root.with(
|
|
118
|
+
agent_revision: root.agent_revision + 1,
|
|
119
|
+
context_revision: root.context_revision + (context_changed ? 1 : 0),
|
|
120
|
+
journal_position: root.journal_position + appended.length,
|
|
121
|
+
lifecycle_status: :idle
|
|
122
|
+
)
|
|
123
|
+
tx.agents.save(
|
|
124
|
+
root.agent_id,
|
|
125
|
+
expected_revision: root.agent_revision,
|
|
126
|
+
root: next_root
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
result = result_base(handed_off, next_root)
|
|
131
|
+
TerminalOutcome.new(
|
|
132
|
+
type: :handed_off,
|
|
133
|
+
execution: handed_off,
|
|
134
|
+
root: next_root,
|
|
135
|
+
appended_records: Array(appended).freeze,
|
|
136
|
+
result: result.freeze,
|
|
137
|
+
error: nil,
|
|
138
|
+
approval_request: nil
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class HandoffPolicy
|
|
6
|
+
CATEGORIES = %i[current_request history knowledge tool_exchanges].freeze
|
|
7
|
+
MODES = %i[required forbidden selectable].freeze
|
|
8
|
+
DEFAULTS = %i[include exclude].freeze
|
|
9
|
+
|
|
10
|
+
Rule = Data.define(:category, :mode, :default) do
|
|
11
|
+
def initialize(category:, mode:, default: nil)
|
|
12
|
+
category = category.to_sym
|
|
13
|
+
mode = mode.to_sym
|
|
14
|
+
unless HandoffPolicy::CATEGORIES.include?(category)
|
|
15
|
+
raise ArgumentError, "unknown Handoff policy category: #{category.inspect}"
|
|
16
|
+
end
|
|
17
|
+
unless HandoffPolicy::MODES.include?(mode)
|
|
18
|
+
raise ArgumentError, "unknown Handoff policy mode: #{mode.inspect}"
|
|
19
|
+
end
|
|
20
|
+
if mode == :selectable
|
|
21
|
+
default = (default || :exclude).to_sym
|
|
22
|
+
unless HandoffPolicy::DEFAULTS.include?(default)
|
|
23
|
+
raise ArgumentError, "selectable default must be :include or :exclude"
|
|
24
|
+
end
|
|
25
|
+
elsif default
|
|
26
|
+
raise ArgumentError, "default is valid only for selectable Handoff rules"
|
|
27
|
+
end
|
|
28
|
+
super
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Builder
|
|
34
|
+
def initialize
|
|
35
|
+
@rules = {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def required(category)
|
|
39
|
+
set(category, :required)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def forbidden(category)
|
|
43
|
+
set(category, :forbidden)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def selectable(category, default: :exclude)
|
|
47
|
+
set(category, :selectable, default: default)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build
|
|
51
|
+
missing = CATEGORIES - @rules.keys
|
|
52
|
+
unless missing.empty?
|
|
53
|
+
raise ArgumentError,
|
|
54
|
+
"HandoffPolicy must define all categories; missing: #{missing.inspect}"
|
|
55
|
+
end
|
|
56
|
+
HandoffPolicy.new(@rules.values)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def set(category, mode, default: nil)
|
|
62
|
+
category = category.to_sym
|
|
63
|
+
if @rules.key?(category)
|
|
64
|
+
raise ArgumentError, "Handoff policy category already defined: #{category.inspect}"
|
|
65
|
+
end
|
|
66
|
+
@rules[category] = Rule.new(category: category, mode: mode, default: default)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private_constant :Rule, :Builder
|
|
72
|
+
|
|
73
|
+
def self.define(&block)
|
|
74
|
+
raise ArgumentError, "HandoffPolicy.define requires a block" unless block
|
|
75
|
+
builder = Builder.new
|
|
76
|
+
builder.instance_eval(&block)
|
|
77
|
+
builder.build
|
|
78
|
+
end
|
|
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
|
+
|
|
86
|
+
def self.default
|
|
87
|
+
@default ||= define do
|
|
88
|
+
required :current_request
|
|
89
|
+
selectable :history, default: :include
|
|
90
|
+
selectable :knowledge, default: :exclude
|
|
91
|
+
selectable :tool_exchanges, default: :include
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def initialize(rules)
|
|
96
|
+
@rules = Array(rules).to_h { |rule| [rule.category, rule] }.freeze
|
|
97
|
+
missing = CATEGORIES - @rules.keys
|
|
98
|
+
extra = @rules.keys - CATEGORIES
|
|
99
|
+
unless missing.empty? && extra.empty?
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"HandoffPolicy categories mismatch; missing=#{missing.inspect} extra=#{extra.inspect}"
|
|
102
|
+
end
|
|
103
|
+
freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def rule(category)
|
|
107
|
+
@rules.fetch(category.to_sym)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def required?(category)
|
|
111
|
+
rule(category).mode == :required
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def forbidden?(category)
|
|
115
|
+
rule(category).mode == :forbidden
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def selectable?(category)
|
|
119
|
+
rule(category).mode == :selectable
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def default_include?(category)
|
|
123
|
+
selected = rule(category)
|
|
124
|
+
selected.mode == :required ||
|
|
125
|
+
(selected.mode == :selectable && selected.default == :include)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def selectable_categories
|
|
129
|
+
CATEGORIES.select { |category| selectable?(category) }.freeze
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def to_h
|
|
133
|
+
CATEGORIES.to_h do |category|
|
|
134
|
+
selected = rule(category)
|
|
135
|
+
[category.to_s, {
|
|
136
|
+
"mode" => selected.mode.to_s,
|
|
137
|
+
"default" => selected.default&.to_s
|
|
138
|
+
}.compact]
|
|
139
|
+
end.freeze
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|