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
@@ -7,12 +7,13 @@ module Phronomy
7
7
  module Agent
8
8
  class AgentExecution
9
9
  ACTIVE_STATUSES = %i[preparing active suspended].freeze
10
- TERMINAL_STATUSES = %i[completed failed cancelled rejected blocked].freeze
10
+ TERMINAL_STATUSES = %i[completed handed_off failed cancelled rejected blocked].freeze
11
11
  TRANSITIONS = {
12
12
  preparing: %i[preparing active failed cancelled blocked],
13
- active: %i[active suspended completed failed cancelled rejected blocked],
13
+ active: %i[active suspended completed handed_off failed cancelled rejected blocked],
14
14
  suspended: %i[suspended active failed cancelled],
15
15
  completed: %i[completed],
16
+ handed_off: %i[handed_off],
16
17
  failed: %i[failed],
17
18
  cancelled: %i[cancelled],
18
19
  rejected: %i[rejected],
@@ -27,10 +28,10 @@ module Phronomy
27
28
  ].freeze
28
29
  attr_reader(*ATTRIBUTES)
29
30
 
30
- def self.start(agent_root:, input_record:, metadata: {})
31
+ def self.start(agent_root:, input_record:, metadata: {}, execution_id: SecureRandom.uuid)
31
32
  now = Time.now.utc.iso8601(6)
32
33
  new(
33
- execution_id: SecureRandom.uuid,
34
+ execution_id: execution_id.to_s,
34
35
  agent_id: agent_root.agent_id,
35
36
  execution_revision: 0,
36
37
  status: :preparing,
@@ -84,10 +85,8 @@ module Phronomy
84
85
  self.class.new(**values)
85
86
  end
86
87
 
87
- # Returns the canonical durable representation of this execution.
88
- # Nested JournalRecord and LLMCallRecord values are recursively encoded.
89
- #
90
- # @return [Hash{String => Object}]
88
+ # Current semantic payload representation. Persistence format identity and
89
+ # compatibility validation are owned by Persistence::DurableCodec.
91
90
  # @api public
92
91
  def to_h
93
92
  ATTRIBUTES.to_h do |name|
@@ -98,18 +97,23 @@ module Phronomy
98
97
  end
99
98
  end
100
99
 
101
- # Restores an execution from its canonical durable representation.
102
- # String and Symbol top-level keys are accepted. Nested working Journal
103
- # records and LLM Call records are restored through their public codecs so
104
- # storage backends do not need to know their constructor details.
105
- #
106
- # @param hash [Hash]
107
- # @return [AgentExecution]
100
+ # Restores only the current semantic payload shape. Historical durable
101
+ # representations must go through explicit Persistence migration first.
108
102
  # @api public
109
103
  def self.from_h(hash)
104
+ source = hash.to_h { |key, value| [key.to_s, value] }
105
+ expected = ATTRIBUTES.map(&:to_s).sort
106
+ actual = source.keys.sort
107
+ unless actual == expected
108
+ missing = expected - actual
109
+ unknown = actual - expected
110
+ raise ArgumentError,
111
+ "AgentExecution payload schema mismatch: " \
112
+ "missing=#{missing.inspect}, unknown=#{unknown.inspect}"
113
+ end
114
+
110
115
  attributes = ATTRIBUTES.to_h do |name|
111
- key = hash.key?(name.to_s) ? name.to_s : name
112
- [name, hash.fetch(key)]
116
+ [name, source.fetch(name.to_s)]
113
117
  end
114
118
 
115
119
  attributes[:working_records] = attributes.fetch(:working_records).map do |record|
@@ -1,17 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require "time"
4
5
 
5
6
  module Phronomy
6
7
  module Agent
7
- # Mutable domain state for one Agent invocation.
8
- #
9
- # AgentInvocation interprets Agent-internal events. FSMSession owns the
10
- # transition mechanics, while Agent::Base projects the terminal outcome to
11
- # both the Application listener and the returned Task.
12
- #
13
- # @api private
14
8
  class AgentInvocation
9
+ # execution_id is the logical Agent execution parent. Concrete Runtime
10
+ # routing identity belongs only to the owning FSMSession incarnation.
15
11
  TOOL_EVENT_TYPES = %i[
16
12
  tool_authorized
17
13
  tool_approval_required
@@ -21,12 +17,25 @@ module Phronomy
21
17
  tool_cancelled
22
18
  ].freeze
23
19
 
24
- LLM_EVENT_TYPES = %i[
25
- llm_completed
26
- llm_failed
27
- ].freeze
28
-
20
+ LLM_EVENT_TYPES = %i[llm_completed llm_failed].freeze
29
21
  CALLBACK_FAILED_EVENTS = %i[application_callback_failed].freeze
22
+ SETUP_FAILED_EVENTS = %i[llm_setup_failed tool_setup_failed].freeze
23
+
24
+ ApplicationCallbackFailure = Data.define(:event_type, :error) do
25
+ def to_stream_callback_error
26
+ wrapped = Phronomy::StreamCallbackError.new(
27
+ event_type: event_type,
28
+ original_error: error,
29
+ result: nil
30
+ )
31
+ begin
32
+ raise wrapped, cause: error
33
+ rescue Phronomy::StreamCallbackError => caught
34
+ caught.set_backtrace(error.backtrace)
35
+ caught
36
+ end
37
+ end
38
+ end
30
39
 
31
40
  attr_accessor :input,
32
41
  :chat,
@@ -41,19 +50,19 @@ module Phronomy
41
50
  :rejected,
42
51
  :error
43
52
 
44
- attr_reader :id,
53
+ attr_reader :execution_id,
45
54
  :agent,
46
55
  :config,
47
- :thread_id,
48
56
  :approval_policy,
49
57
  :approval_listener,
50
58
  :pending_tool_calls,
51
59
  :tool_invocations,
52
- :session_id,
53
60
  :phase,
54
61
  :mode,
55
- :current_llm_call_id,
56
- :tool_batch_llm_call_id
62
+ :tool_batch_llm_call_id,
63
+ :handoff_request,
64
+ :callback_failure,
65
+ :event_sink
57
66
 
58
67
  def initialize(
59
68
  agent:,
@@ -63,20 +72,22 @@ module Phronomy
63
72
  approval_listener: nil,
64
73
  event_listener: nil,
65
74
  mode: nil,
66
- id: nil
75
+ execution_id: nil
67
76
  )
68
77
  @agent = agent
69
78
  @input = input
70
- @config = config
71
- @thread_id = config[:thread_id]
72
- @id = (id || config[:agent_invocation_id] || SecureRandom.uuid).to_s
73
- invocation_context = config[:invocation_context]
79
+ # The caller's Hash must not become a shared mutable Runtime object.
80
+ @config = config.dup
81
+ resolved_execution_id = execution_id || @config[:execution_id]
82
+ @execution_id = resolved_execution_id&.to_s&.freeze
83
+ invocation_context = @config[:invocation_context]
74
84
  invocation_policy = if invocation_context&.respond_to?(:approval_policy)
75
85
  invocation_context.approval_policy
76
86
  end
77
87
  @approval_policy = invocation_policy || approval_policy
78
88
  @approval_listener = approval_listener
79
89
  @event_listener = event_listener
90
+ @event_sink = nil
80
91
  @mode = (mode || :invoke).to_sym
81
92
 
82
93
  @chat = nil
@@ -94,26 +105,68 @@ module Phronomy
94
105
  @approval_resume_in_progress = false
95
106
  @pending_approval_resolution_ids = []
96
107
  @error = nil
97
- @session_id = nil
98
108
  @phase = nil
99
- @current_llm_call_id = nil
109
+ @current_llm_call = nil
100
110
  @tool_batch_llm_call_id = nil
111
+ @handoff_request = nil
112
+ @llm_results = []
113
+ @runtime_events = []
114
+ @callback_failure = nil
101
115
  end
102
116
 
103
117
  def streaming?
104
118
  @mode == :stream
105
119
  end
106
120
 
107
- def set_graph_metadata(thread_id: nil, phase: nil)
108
- @session_id = thread_id if thread_id
121
+ def set_graph_metadata(phase: nil)
109
122
  @phase = phase
110
123
  end
111
124
 
112
- def begin_llm_call!(llm_call_id)
113
- @current_llm_call_id = llm_call_id.to_s
125
+ # Binds the Runtime-only event target of the currently owning FSMSession.
126
+ # A resume creates a new FSMSession incarnation and therefore replaces the
127
+ # previous sink on EventLoop before any resumed work starts.
128
+ def bind_event_sink!(sink)
129
+ @event_sink = sink
114
130
  self
115
131
  end
116
132
 
133
+ def current_llm_call_id
134
+ @current_llm_call&.fetch(:llm_call_id)
135
+ end
136
+
137
+ # Allocates Provider Call identity on EventLoop before transport begins.
138
+ # The optional explicit ID is retained for focused internal tests; normal
139
+ # runtime code leaves it nil and receives a fresh UUID.
140
+ def begin_llm_call!(projection, llm_call_id: nil)
141
+ if @current_llm_call
142
+ raise Phronomy::Error,
143
+ "cannot start a Provider Call while another Provider Call is active"
144
+ end
145
+
146
+ durable_id = llm_call_id
147
+ if durable_id.nil? &&
148
+ Phronomy::Runtime.instance.event_loop.current?
149
+ state =
150
+ Phronomy::Runtime.instance.event_loop.agent_execution_state(
151
+ execution_id
152
+ )
153
+ durable_id = state&.execution&.metadata&.fetch(
154
+ RecoverySupport::PENDING_LLM_ID_KEY,
155
+ nil
156
+ )
157
+ end
158
+ unless durable_id
159
+ raise Phronomy::ExecutionRehydrationRequiredError,
160
+ "Provider Call semantic identity was not durably established before dispatch"
161
+ end
162
+
163
+ @current_llm_call = {
164
+ llm_call_id: durable_id.to_s.freeze,
165
+ manifest_ref: projection.manifest_ref.to_s.freeze,
166
+ started_at: Time.now.utc.iso8601(6).freeze
167
+ }.freeze
168
+ end
169
+
117
170
  def pending_tool_calls=(calls)
118
171
  @pending_tool_calls = Array(calls)
119
172
  end
@@ -131,17 +184,23 @@ module Phronomy
131
184
 
132
185
  def handle_fsm_event(event)
133
186
  if event.type == :llm_stream_chunk
187
+ payload = event.payload || {}
188
+ return :consume unless current_llm_result_authoritative?(payload[:llm_call_id])
189
+
134
190
  deliver_event(
135
- StreamEvent.new(
136
- type: :token,
137
- payload: {content: event.payload.fetch(:content)}
138
- )
191
+ StreamEvent.new(type: :token, payload: {content: payload.fetch(:content)})
139
192
  )
140
193
  return true
141
194
  end
142
195
 
143
196
  if LLM_EVENT_TYPES.include?(event.type)
144
- apply_llm_event(event)
197
+ return apply_llm_event(event)
198
+ end
199
+
200
+ if SETUP_FAILED_EVENTS.include?(event.type)
201
+ @error ||= event.payload.is_a?(Exception) ? event.payload :
202
+ event.payload&.fetch(:error, nil)
203
+ @error ||= Phronomy::Error.new("external operation setup failed without an error")
145
204
  return true
146
205
  end
147
206
 
@@ -152,27 +211,47 @@ module Phronomy
152
211
 
153
212
  return false unless TOOL_EVENT_TYPES.include?(event.type)
154
213
 
155
- invocation = tool_invocation(
156
- event.payload&.fetch(:tool_invocation_id, nil)
157
- )
214
+ invocation = tool_invocation(event.payload&.fetch(:tool_invocation_id, nil))
158
215
  return true unless invocation
159
216
 
160
217
  @error ||= invocation.error if invocation.failed? || invocation.cancelled?
161
218
  @rejected = true if invocation.rejected?
162
219
  if @approval_resume_in_progress &&
163
220
  @pending_approval_resolution_ids.delete(invocation.id)
164
- if @pending_approval_resolution_ids.empty?
165
- @approval_resume_in_progress = false
166
- end
221
+ @approval_resume_in_progress = false if @pending_approval_resolution_ids.empty?
167
222
  end
168
223
  true
169
224
  end
170
225
 
171
226
  def accept_tool_calls!(tool_calls, llm_call_id: nil)
172
227
  @user_message_sent = true
173
- @pending_tool_calls = Array(tool_calls)
174
- @tool_batch_llm_call_id = (llm_call_id || @current_llm_call_id)&.to_s
175
- @current_llm_call_id = nil
228
+ calls = Array(tool_calls)
229
+ call_llm_id = (llm_call_id || current_llm_call_id)&.to_s
230
+ handoff_matches = handoff_matches_for(calls)
231
+
232
+ unless handoff_matches.empty?
233
+ if handoff_matches.length != 1 || calls.length != 1
234
+ @error = Phronomy::HandoffError.new(
235
+ "one Provider outcome cannot mix Handoff with ordinary Tool Calls or multiple Handoffs"
236
+ )
237
+ @pending_tool_calls = []
238
+ @tool_batch_llm_call_id = call_llm_id
239
+ return self
240
+ end
241
+
242
+ tool_call, binding = handoff_matches.first
243
+ @handoff_request = build_handoff_request(
244
+ tool_call,
245
+ binding,
246
+ llm_call_id: call_llm_id
247
+ )
248
+ @pending_tool_calls = []
249
+ @tool_batch_llm_call_id = call_llm_id
250
+ return self
251
+ end
252
+
253
+ @pending_tool_calls = calls
254
+ @tool_batch_llm_call_id = call_llm_id
176
255
  @pending_tool_calls.each do |tool_call|
177
256
  deliver_event(
178
257
  StreamEvent.new(
@@ -185,18 +264,28 @@ module Phronomy
185
264
  )
186
265
  end
187
266
  self
267
+ rescue => caught
268
+ @error = caught.is_a?(Phronomy::HandoffError) ? caught :
269
+ Phronomy::HandoffError.new("invalid Handoff request: #{caught.message}")
270
+ @pending_tool_calls = []
271
+ self
272
+ end
273
+
274
+ def handoff_requested?
275
+ !@handoff_request.nil?
276
+ end
277
+
278
+ def handoff_failed?
279
+ @error.is_a?(Phronomy::HandoffError) && !handoff_requested?
188
280
  end
189
281
 
190
282
  def apply_llm_response!(response)
191
- unless response
192
- raise Phronomy::Error, "LLM operation completed without a response"
193
- end
283
+ raise Phronomy::Error, "LLM operation completed without a response" unless response
194
284
 
195
285
  @user_message_sent = true
196
286
  @output = response.content
197
287
  @usage = Phronomy::TokenUsage.from_tokens(response.tokens)
198
288
  @pending_tool_calls = []
199
- @current_llm_call_id = nil
200
289
  self
201
290
  end
202
291
 
@@ -215,13 +304,8 @@ module Phronomy
215
304
  context = @config[:invocation_context]
216
305
  return {} unless context
217
306
 
218
- %i[
219
- thread_id session_id user_id token_budget
220
- task_id parent_task_id
221
- ].each_with_object({}) do |name, result|
222
- if context.respond_to?(name)
223
- result[name] = context.public_send(name)
224
- end
307
+ %i[user_id token_budget task_id parent_task_id].each_with_object({}) do |name, result|
308
+ result[name] = context.public_send(name) if context.respond_to?(name)
225
309
  end
226
310
  end
227
311
 
@@ -230,8 +314,7 @@ module Phronomy
230
314
  @pending_approval_resolution_ids = @tool_invocations
231
315
  .select(&:awaiting_approval?)
232
316
  .map(&:id)
233
- @approval_resume_in_progress =
234
- !@pending_approval_resolution_ids.empty?
317
+ @approval_resume_in_progress = !@pending_approval_resolution_ids.empty?
235
318
  self
236
319
  end
237
320
 
@@ -269,29 +352,40 @@ module Phronomy
269
352
  self
270
353
  end
271
354
 
272
- def input_passed?
273
- !@input_blocked
274
- end
275
-
276
- def input_blocked?
277
- @input_blocked
355
+ # EventLoop-owned snapshot handed to one durable Offload operation.
356
+ # Values already canonicalized by Phronomy are immutable; the arrays are
357
+ # copied/frozen so worker code cannot mutate the live queues.
358
+ def runtime_snapshot
359
+ {
360
+ llm_results: @llm_results.dup.freeze,
361
+ runtime_events: @runtime_events.dup.freeze,
362
+ active_call: @current_llm_call
363
+ }.freeze
278
364
  end
279
365
 
280
- def output_passed?
281
- !@output_blocked
366
+ # Applies a successful durable barrier on EventLoop. Facts appended after
367
+ # the snapshot was taken remain queued for the next barrier.
368
+ def acknowledge_runtime_snapshot(snapshot)
369
+ @llm_results.shift(snapshot.fetch(:llm_results).length)
370
+ @runtime_events.shift(snapshot.fetch(:runtime_events).length)
371
+ if snapshot[:active_call] && @current_llm_call == snapshot[:active_call]
372
+ @current_llm_call = nil
373
+ end
374
+ self
282
375
  end
283
376
 
284
- def output_blocked?
285
- @output_blocked
377
+ def callback_failed?
378
+ !@callback_failure.nil?
286
379
  end
287
380
 
288
- def tool_call_pending?
289
- !@pending_tool_calls.empty?
290
- end
381
+ def input_passed? = !@input_blocked
382
+ def input_blocked? = @input_blocked
383
+ def output_passed? = !@output_blocked
384
+ def output_blocked? = @output_blocked
385
+ def tool_call_pending? = !@pending_tool_calls.empty?
291
386
 
292
387
  def preflight_complete?
293
- !@tool_invocations.empty? &&
294
- @tool_invocations.all?(&:preflight_settled?)
388
+ !@tool_invocations.empty? && @tool_invocations.all?(&:preflight_settled?)
295
389
  end
296
390
 
297
391
  def approval_required?
@@ -312,24 +406,19 @@ module Phronomy
312
406
  end
313
407
 
314
408
  def tool_batch_terminal?
315
- !@tool_invocations.empty? &&
316
- @tool_invocations.all?(&:terminal?)
409
+ !@tool_invocations.empty? && @tool_invocations.all?(&:terminal?)
317
410
  end
318
411
 
319
412
  def tool_batch_failed?
320
413
  return false if @human_rejection || @approval_resume_in_progress
321
414
 
322
415
  no_rejection = @tool_invocations.none?(&:rejected?)
323
- preflight_failure =
324
- preflight_complete? &&
325
- @tool_invocations.any? do |invocation|
326
- invocation.failed? || invocation.cancelled?
327
- end
328
- terminal_failure =
329
- tool_batch_terminal? &&
330
- @tool_invocations.any? do |invocation|
331
- invocation.failed? || invocation.cancelled?
332
- end
416
+ preflight_failure = preflight_complete? && @tool_invocations.any? do |invocation|
417
+ invocation.failed? || invocation.cancelled?
418
+ end
419
+ terminal_failure = tool_batch_terminal? && @tool_invocations.any? do |invocation|
420
+ invocation.failed? || invocation.cancelled?
421
+ end
333
422
  no_rejection && (preflight_failure || terminal_failure)
334
423
  end
335
424
 
@@ -340,43 +429,149 @@ module Phronomy
340
429
  end
341
430
 
342
431
  def tool_batch_completed?
343
- tool_batch_terminal? &&
344
- @tool_invocations.all?(&:execution_completed?)
432
+ tool_batch_terminal? && @tool_invocations.all?(&:execution_completed?)
345
433
  end
346
434
 
347
435
  private
348
436
 
437
+ def handoff_matches_for(calls)
438
+ bindings = Array(@config[:phronomy_handoff_bindings])
439
+ return [] if bindings.empty?
440
+
441
+ by_name = bindings.to_h { |binding| [binding.tool_name.to_s, binding] }
442
+ calls.filter_map do |tool_call|
443
+ binding = by_name[tool_call.name.to_s]
444
+ binding && [tool_call, binding]
445
+ end
446
+ end
447
+
448
+ def build_handoff_request(tool_call, binding, llm_call_id:)
449
+ handoff = binding.handoff
450
+ unless handoff.source_agent.equal?(@agent)
451
+ raise Phronomy::HandoffError,
452
+ "Handoff capability is not bound to the active Source Agent"
453
+ end
454
+
455
+ args = tool_call.respond_to?(:arguments) ? tool_call.arguments : {}
456
+ args = (args || {}).to_h.transform_keys(&:to_sym)
457
+ selection = handoff.policy.selectable_categories.each_with_object({}) do |category, result|
458
+ key = :"include_#{category}"
459
+ result[category] = args[key] if args.key?(key)
460
+ end
461
+ Phronomy::Agent::HandoffRequest.new(
462
+ handoff: handoff,
463
+ responsibility: args.fetch(:responsibility),
464
+ selection_intent: selection,
465
+ llm_call_id: llm_call_id,
466
+ tool_call_id: tool_call.respond_to?(:id) ? tool_call.id : nil
467
+ )
468
+ end
469
+
349
470
  def apply_llm_event(event)
350
471
  result = event.payload
351
472
  unless result.is_a?(LLMOperationResult)
352
- raise Phronomy::Error,
353
- "Expected LLMOperationResult, got #{result.class}"
473
+ raise Phronomy::Error, "Expected LLMOperationResult, got #{result.class}"
474
+ end
475
+
476
+ unless current_llm_result_authoritative?(result.llm_call_id)
477
+ warn_stale_llm_result(result.llm_call_id)
478
+ return :consume
354
479
  end
355
480
 
481
+ call = @current_llm_call
482
+ @llm_results << {
483
+ llm_call_id: call.fetch(:llm_call_id),
484
+ response: canonical_response_for_llm_result(result),
485
+ error: result.error,
486
+ streaming: result.streaming,
487
+ manifest_ref: call.fetch(:manifest_ref),
488
+ started_at: call.fetch(:started_at)
489
+ }.freeze
490
+ @current_llm_call = nil
491
+
356
492
  if event.type == :llm_failed
357
- @current_llm_call_id = nil
358
- @error = result.error ||
359
- Phronomy::Error.new("LLM operation failed without an error")
360
- return
493
+ @error = result.error || Phronomy::Error.new("LLM operation failed without an error")
494
+ return true
361
495
  end
362
496
 
363
497
  if result.error
364
498
  if result.error.is_a?(ToolCallIntercepted)
365
499
  accept_tool_calls!(
366
500
  result.error.tool_calls,
367
- llm_call_id: result.error.llm_call_id
501
+ llm_call_id: result.llm_call_id
368
502
  )
369
503
  else
370
- @current_llm_call_id = nil
371
504
  @error = result.error
372
505
  end
373
506
  else
374
507
  apply_llm_response!(result.response)
375
508
  end
509
+ true
510
+ end
511
+
512
+ def current_llm_result_authoritative?(llm_call_id)
513
+ current = current_llm_call_id
514
+ !current.nil? && current == llm_call_id&.to_s
515
+ end
516
+
517
+ def warn_stale_llm_result(llm_call_id)
518
+ Phronomy.configuration.logger&.warn(
519
+ "[Phronomy] Dropped stale Provider result: " \
520
+ "execution_id=#{@execution_id} expected_llm_call_id=#{current_llm_call_id.inspect} " \
521
+ "actual_llm_call_id=#{llm_call_id.inspect}"
522
+ )
523
+ rescue
524
+ nil
525
+ end
526
+
527
+ def canonical_response_for_llm_result(result)
528
+ if result.error.is_a?(ToolCallIntercepted)
529
+ result.error.assistant_outcome || ProviderCallOutcome.capture(result.response)
530
+ else
531
+ ProviderCallOutcome.capture(result.response)
532
+ end
376
533
  end
377
534
 
535
+ # Canonical runtime recording is independent of Application callback health.
536
+ # Once an event is observed it is appended even after a listener has failed.
378
537
  def deliver_event(event)
379
- @event_listener&.call(event)
538
+ # Canonical runtime facts must not share mutable payload containers with
539
+ # Application callbacks. Record an EventLoop-owned snapshot first; the
540
+ # listener receives the notification event separately.
541
+ @runtime_events << StreamEvent.new(
542
+ type: event.type,
543
+ payload: Immutable.copy(event.payload)
544
+ )
545
+ listener = @callback_failure ? nil : @event_listener
546
+ return unless listener
547
+
548
+ listener.call(event)
549
+ rescue => callback_error
550
+ failure = ApplicationCallbackFailure.new(
551
+ event_type: event.type,
552
+ error: callback_error
553
+ )
554
+ @callback_failure ||= failure
555
+ @event_listener = nil
556
+ notify_callback_failure(failure)
557
+ end
558
+
559
+ def notify_callback_failure(failure)
560
+ if @event_sink
561
+ accepted = @event_sink.post(:application_callback_failed, {failure: failure})
562
+ unless accepted
563
+ Phronomy.configuration.logger&.warn(
564
+ "[Phronomy] Callback failure recorded but could not notify " \
565
+ "FSMSession #{@event_sink.fsm_session_id}: execution_id=#{@execution_id}"
566
+ )
567
+ end
568
+ end
569
+ Phronomy.configuration.logger&.warn(
570
+ "[Phronomy] Application event listener failed: " \
571
+ "#{failure.error.class}: #{failure.error.message}"
572
+ )
573
+ rescue
574
+ nil
380
575
  end
381
576
  end
382
577
  end