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.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +147 -0
  15. data/docs/architecture/persistence.md +186 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +89 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. 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