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,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class HandoffProjection
|
|
6
|
+
CONTROL_CATEGORIES = %i[instruction handoff_responsibility].freeze
|
|
7
|
+
JSON_CATEGORIES = %i[assistant_message tool_message].freeze
|
|
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
|
+
|
|
25
|
+
def build(request:, manifest:, persistence:, source_agent:)
|
|
26
|
+
unless request.is_a?(HandoffRequest) || request.is_a?(Snapshot)
|
|
27
|
+
raise ArgumentError, "request must be a HandoffRequest"
|
|
28
|
+
end
|
|
29
|
+
unless manifest.is_a?(Phronomy::Agent::LLMInputManifest)
|
|
30
|
+
raise ArgumentError, "manifest must be an LLMInputManifest"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
groups = project_visible_groups(manifest)
|
|
34
|
+
selected = groups.values.select do |group|
|
|
35
|
+
include_group?(request, group.fetch(:policy_category))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
items = selected.flat_map do |group|
|
|
39
|
+
group.fetch(:segments).map do |segment|
|
|
40
|
+
materialize_item(
|
|
41
|
+
segment,
|
|
42
|
+
policy_category: group.fetch(:policy_category),
|
|
43
|
+
persistence: persistence,
|
|
44
|
+
source_agent: source_agent,
|
|
45
|
+
target_agent: request.handoff.target_agent
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
HandoffContext.new(
|
|
51
|
+
responsibility: request.responsibility,
|
|
52
|
+
items: items
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def project_visible_groups(manifest)
|
|
59
|
+
groups = {}
|
|
60
|
+
manifest.segments.each do |segment|
|
|
61
|
+
policy_category = policy_category_for(segment)
|
|
62
|
+
next unless policy_category
|
|
63
|
+
|
|
64
|
+
key = selection_group_key(segment, policy_category)
|
|
65
|
+
group = groups[key] ||= {
|
|
66
|
+
policy_category: policy_category,
|
|
67
|
+
segments: []
|
|
68
|
+
}
|
|
69
|
+
if group[:policy_category] != policy_category
|
|
70
|
+
raise Phronomy::HandoffError,
|
|
71
|
+
"one Handoff projection group spans incompatible policy categories"
|
|
72
|
+
end
|
|
73
|
+
group[:segments] << segment
|
|
74
|
+
end
|
|
75
|
+
groups
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def policy_category_for(segment)
|
|
79
|
+
explicit = segment.metadata["handoff_policy_category"] ||
|
|
80
|
+
segment.metadata[:handoff_policy_category]
|
|
81
|
+
return explicit.to_sym if explicit
|
|
82
|
+
|
|
83
|
+
semantic_category =
|
|
84
|
+
segment.metadata["context_policy_semantic_category"] ||
|
|
85
|
+
segment.metadata[:context_policy_semantic_category]
|
|
86
|
+
case semantic_category&.to_sym
|
|
87
|
+
when :instruction
|
|
88
|
+
return nil
|
|
89
|
+
when :knowledge
|
|
90
|
+
return :knowledge
|
|
91
|
+
when :conversation
|
|
92
|
+
return :current_request if segment.delivery.to_sym == :ask_argument
|
|
93
|
+
return :history
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return :current_request if segment.delivery.to_sym == :ask_argument
|
|
97
|
+
return nil if CONTROL_CATEGORIES.include?(segment.category.to_sym)
|
|
98
|
+
return :knowledge if segment.category.to_sym == :knowledge
|
|
99
|
+
unit_kind = segment.metadata["selection_unit_kind"] ||
|
|
100
|
+
segment.metadata[:selection_unit_kind]
|
|
101
|
+
return :tool_exchanges if unit_kind.to_s == "tool_exchange"
|
|
102
|
+
|
|
103
|
+
case segment.category.to_sym
|
|
104
|
+
when :external_message, :assistant_message, :tool_message,
|
|
105
|
+
:conversation, :memory, :summary, :structured_state
|
|
106
|
+
:history
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def selection_group_key(segment, policy_category)
|
|
111
|
+
conversation_group_id =
|
|
112
|
+
segment.metadata["context_policy_conversation_group_id"] ||
|
|
113
|
+
segment.metadata[:context_policy_conversation_group_id]
|
|
114
|
+
if conversation_group_id
|
|
115
|
+
return "context-policy-conversation:#{conversation_group_id}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Compatibility for finalized pre-ACS-04 manifests. New manifests use
|
|
119
|
+
# context_policy_conversation_group_id and do not recreate Selection::Unit.
|
|
120
|
+
unit_id = segment.metadata["selection_unit_id"] ||
|
|
121
|
+
segment.metadata[:selection_unit_id]
|
|
122
|
+
return "unit:#{unit_id}" if unit_id
|
|
123
|
+
|
|
124
|
+
"segment:#{policy_category}:#{segment.position}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def include_group?(request, category)
|
|
128
|
+
policy = request.handoff.policy
|
|
129
|
+
return true if policy.required?(category)
|
|
130
|
+
return false if policy.forbidden?(category)
|
|
131
|
+
|
|
132
|
+
request.selection_intent.fetch(category) do
|
|
133
|
+
policy.default_include?(category)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def materialize_item(segment, policy_category:, persistence:, source_agent:, target_agent:)
|
|
138
|
+
bytes = persistence.contents.fetch(segment.content_ref)
|
|
139
|
+
category = segment.category.to_sym
|
|
140
|
+
metadata = segment.metadata.to_h.transform_keys(&:to_s)
|
|
141
|
+
format = content_format_for(segment, metadata)
|
|
142
|
+
content = (format == :json) ? Phronomy::CanonicalJSON.load(bytes) : bytes.to_s
|
|
143
|
+
provenance = provenance_for(
|
|
144
|
+
metadata,
|
|
145
|
+
segment: segment,
|
|
146
|
+
source_agent: source_agent,
|
|
147
|
+
target_agent: target_agent
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
HandoffContext::Item.new(
|
|
151
|
+
candidate_category: category,
|
|
152
|
+
policy_category: policy_category,
|
|
153
|
+
role: segment.role,
|
|
154
|
+
content: content,
|
|
155
|
+
content_format: format,
|
|
156
|
+
tool_call_id: segment.tool_call_id,
|
|
157
|
+
provenance: provenance,
|
|
158
|
+
metadata: metadata.except(
|
|
159
|
+
"context_policy_content_format",
|
|
160
|
+
"context_policy_conversation_group_id",
|
|
161
|
+
"selection_candidate_id", "selection_unit_id", "selection_unit_kind",
|
|
162
|
+
"handoff_policy_category", "handoff_provenance"
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def content_format_for(segment, metadata)
|
|
168
|
+
explicit = metadata["context_policy_content_format"] ||
|
|
169
|
+
metadata[:context_policy_content_format]
|
|
170
|
+
if explicit
|
|
171
|
+
format = explicit.to_sym
|
|
172
|
+
unless Phronomy::Agent::ContextPolicyInput::CONTENT_FORMATS.include?(format)
|
|
173
|
+
raise Phronomy::HandoffError,
|
|
174
|
+
"unsupported Context content format in Manifest: #{explicit.inspect}"
|
|
175
|
+
end
|
|
176
|
+
return format
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
JSON_CATEGORIES.include?(segment.category.to_sym) ? :json : :text
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def provenance_for(metadata, segment:, source_agent:, target_agent:)
|
|
183
|
+
inherited = metadata["handoff_provenance"]
|
|
184
|
+
if inherited
|
|
185
|
+
base = HandoffContext::Provenance.new(
|
|
186
|
+
origin_agent_id: inherited["origin_agent_id"] || inherited[:origin_agent_id],
|
|
187
|
+
origin_record_id: inherited["origin_record_id"] || inherited[:origin_record_id],
|
|
188
|
+
origin_execution_id: inherited["origin_execution_id"] || inherited[:origin_execution_id],
|
|
189
|
+
origin_llm_call_id: inherited["origin_llm_call_id"] || inherited[:origin_llm_call_id],
|
|
190
|
+
origin_tool_call_id: inherited["origin_tool_call_id"] || inherited[:origin_tool_call_id],
|
|
191
|
+
transfer_path: inherited["transfer_path"] || inherited[:transfer_path]
|
|
192
|
+
)
|
|
193
|
+
return base.forwarded_to(target_agent.agent_id)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
HandoffContext::Provenance.new(
|
|
197
|
+
origin_agent_id: metadata["source_agent_id"] || source_agent.agent_id,
|
|
198
|
+
origin_record_id: metadata["journal_record_id"],
|
|
199
|
+
origin_execution_id: metadata["source_execution_id"],
|
|
200
|
+
origin_llm_call_id: metadata["llm_call_id"],
|
|
201
|
+
origin_tool_call_id: segment.tool_call_id,
|
|
202
|
+
transfer_path: [source_agent.agent_id, target_agent.agent_id]
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Typed private control request produced from one intercepted Handoff capability.
|
|
6
|
+
# @api private
|
|
7
|
+
HandoffRequest = Data.define(
|
|
8
|
+
:handoff, :responsibility, :selection_intent, :llm_call_id, :tool_call_id
|
|
9
|
+
) do
|
|
10
|
+
def initialize(handoff:, responsibility:, selection_intent:, llm_call_id: nil, tool_call_id: nil)
|
|
11
|
+
responsibility = responsibility.to_s.strip
|
|
12
|
+
raise ArgumentError, "Handoff responsibility must not be empty" if responsibility.empty?
|
|
13
|
+
unless handoff.is_a?(Handoff)
|
|
14
|
+
raise ArgumentError, "handoff must be a Phronomy::Agent::Handoff"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
normalized = handoff.policy.selectable_categories.to_h do |category|
|
|
18
|
+
raw = selection_intent.fetch(category) do
|
|
19
|
+
handoff.policy.default_include?(category)
|
|
20
|
+
end
|
|
21
|
+
unless raw == true || raw == false
|
|
22
|
+
raise ArgumentError,
|
|
23
|
+
"Handoff selection for #{category.inspect} must be boolean"
|
|
24
|
+
end
|
|
25
|
+
[category, raw]
|
|
26
|
+
end.freeze
|
|
27
|
+
|
|
28
|
+
unknown = selection_intent.keys.map(&:to_sym) - handoff.policy.selectable_categories
|
|
29
|
+
unless unknown.empty?
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"Handoff selection attempts to override non-selectable categories: #{unknown.inspect}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
super(
|
|
35
|
+
handoff: handoff,
|
|
36
|
+
responsibility: responsibility.freeze,
|
|
37
|
+
selection_intent: normalized,
|
|
38
|
+
llm_call_id: llm_call_id&.to_s&.freeze,
|
|
39
|
+
tool_call_id: tool_call_id&.to_s&.freeze
|
|
40
|
+
)
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -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
|
|
@@ -13,7 +13,7 @@ module Phronomy
|
|
|
13
13
|
class JournalRecord
|
|
14
14
|
ATTRIBUTES = %i[
|
|
15
15
|
record_id agent_id sequence execution_id llm_call_id kind channel role content_ref
|
|
16
|
-
parent_id causation_id
|
|
16
|
+
parent_id causation_id visibility context_generation
|
|
17
17
|
context_candidate occurred_at metadata
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
@@ -31,7 +31,6 @@ module Phronomy
|
|
|
31
31
|
content_ref: nil,
|
|
32
32
|
parent_id: nil,
|
|
33
33
|
causation_id: nil,
|
|
34
|
-
correlation_id: nil,
|
|
35
34
|
visibility: :agent,
|
|
36
35
|
context_generation: 0,
|
|
37
36
|
context_candidate: false,
|
|
@@ -52,14 +51,28 @@ module Phronomy
|
|
|
52
51
|
self.class.from_h(to_h.merge("sequence" => sequence))
|
|
53
52
|
end
|
|
54
53
|
|
|
54
|
+
# Current semantic payload representation. Persistence format identity and
|
|
55
|
+
# compatibility validation are owned by Persistence::DurableCodec.
|
|
55
56
|
def to_h
|
|
56
57
|
ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
# Historical durable fields are not accepted here. Explicit migration must
|
|
61
|
+
# convert them to the current semantic payload first.
|
|
59
62
|
def self.from_h(hash)
|
|
63
|
+
source = hash.to_h { |key, value| [key.to_s, value] }
|
|
64
|
+
expected = ATTRIBUTES.map(&:to_s).sort
|
|
65
|
+
actual = source.keys.sort
|
|
66
|
+
unless actual == expected
|
|
67
|
+
missing = expected - actual
|
|
68
|
+
unknown = actual - expected
|
|
69
|
+
raise ArgumentError,
|
|
70
|
+
"JournalRecord payload schema mismatch: " \
|
|
71
|
+
"missing=#{missing.inspect}, unknown=#{unknown.inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
60
74
|
new(**ATTRIBUTES.to_h do |name|
|
|
61
|
-
|
|
62
|
-
[name, hash[key]]
|
|
75
|
+
[name, source.fetch(name.to_s)]
|
|
63
76
|
end)
|
|
64
77
|
end
|
|
65
78
|
end
|
|
@@ -5,7 +5,7 @@ module Phronomy
|
|
|
5
5
|
# Immutable metadata passed to before_llm_input hooks. Runtime objects such
|
|
6
6
|
# as RubyLLM::Chat, messages and the mutable Agent instance are not exposed.
|
|
7
7
|
LLMInputBuildContext = Data.define(
|
|
8
|
-
:agent_id, :agent_definition_id, :
|
|
8
|
+
:agent_id, :agent_definition_id, :agent_definition_version,
|
|
9
9
|
:config, :call_sequence
|
|
10
10
|
) do
|
|
11
11
|
def initialize(**values)
|