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
@@ -2,13 +2,6 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Builds FSMSession instances for AgentInvocation objects.
6
- #
7
- # Offloaded/provider work returns through explicit Agent-internal events.
8
- # Entry actions start operations and return synchronously.
9
- # Every LLM Call is prepared from a canonical Manifest and RuntimeProjection.
10
- #
11
- # @api private
12
5
  class AgentInvocationSessionBuilder
13
6
  AUTO_STATE_SET = {
14
7
  idle: true,
@@ -16,7 +9,6 @@ module Phronomy
16
9
  building_context: true,
17
10
  starting_tools: true,
18
11
  evaluating_tools: true,
19
- dispatching_tools: true,
20
12
  recording_tool_results: true,
21
13
  output_filtering: true
22
14
  }.freeze
@@ -24,7 +16,7 @@ module Phronomy
24
16
  DECLARED_STATES = %i[
25
17
  idle filtering_input building_context calling_llm starting_tools
26
18
  evaluating_tools waiting_for_tools dispatching_tools
27
- recording_tool_results suspended output_filtering completed blocked failed
19
+ recording_tool_results suspended output_filtering handed_off completed blocked failed
28
20
  ].freeze
29
21
 
30
22
  WAIT_STATES = %i[suspended].freeze
@@ -55,7 +47,8 @@ module Phronomy
55
47
  approval_policy: approval_policy,
56
48
  approval_listener: approval_listener,
57
49
  event_listener: on_event,
58
- mode: mode
50
+ mode: mode,
51
+ execution_id: config.fetch(:execution_id)
59
52
  )
60
53
  build_session(
61
54
  agent_invocation: invocation,
@@ -87,15 +80,19 @@ module Phronomy
87
80
  resume_phase: nil
88
81
  )
89
82
  agent = agent_invocation.agent
90
- actions = build_entry_actions(agent, runtime, mode: mode)
91
- phase_machine = Agent::PhaseMachineBuilder.new(
92
- entry_actions: actions
93
- ).build
83
+ event_sink = Phronomy::FSMSession::EventSink.new(
84
+ event_loop: runtime.event_loop
85
+ )
86
+ agent_invocation.bind_event_sink!(event_sink)
87
+ actions = build_entry_actions(
88
+ agent, runtime, mode: mode, event_sink: event_sink
89
+ )
90
+ phase_machine = Agent::PhaseMachineBuilder.new(entry_actions: actions).build
94
91
  iterations = agent.class.max_iterations || 10
95
92
 
96
93
  Phronomy::FSMSession.new(
97
- id: agent_invocation.id,
98
94
  context: agent_invocation,
95
+ event_sink: event_sink,
99
96
  entry_point: :idle,
100
97
  phase_machine_class: phase_machine,
101
98
  entry_actions: {},
@@ -113,22 +110,29 @@ module Phronomy
113
110
 
114
111
  def self.external_events
115
112
  tool_transitions = TOOL_EVENTS.to_h do |event_name|
116
- [
117
- event_name,
118
- [{from: :waiting_for_tools, to: :evaluating_tools, guard: nil}]
119
- ]
113
+ [event_name, [{from: :waiting_for_tools, to: :evaluating_tools, guard: nil}]]
120
114
  end
121
115
 
122
116
  tool_transitions.merge(
123
117
  llm_completed: [
124
- {from: :calling_llm, to: :starting_tools, guard: ->(ctx) {
125
- ctx.tool_call_pending?
126
- }},
118
+ {from: :calling_llm, to: :failed, guard: ->(ctx) { ctx.callback_failed? }},
119
+ {from: :calling_llm, to: :failed, guard: ->(ctx) { ctx.handoff_failed? }},
120
+ {from: :calling_llm, to: :handed_off, guard: ->(ctx) { ctx.handoff_requested? }},
121
+ {from: :calling_llm, to: :starting_tools, guard: ->(ctx) { ctx.tool_call_pending? }},
127
122
  {from: :calling_llm, to: :output_filtering, guard: nil}
128
123
  ],
129
124
  llm_failed: [
130
125
  {from: :calling_llm, to: :failed, guard: nil}
131
126
  ],
127
+ llm_setup_failed: [
128
+ {from: :calling_llm, to: :failed, guard: nil}
129
+ ],
130
+ tool_setup_failed: [
131
+ {from: :dispatching_tools, to: :failed, guard: nil}
132
+ ],
133
+ tool_dispatch_prepared: [
134
+ {from: :dispatching_tools, to: :evaluating_tools, guard: nil}
135
+ ],
132
136
  resume: [
133
137
  {from: :suspended, to: :waiting_for_tools, guard: nil}
134
138
  ],
@@ -147,34 +151,22 @@ module Phronomy
147
151
  end
148
152
  private_class_method :external_events
149
153
 
150
- def self.build_entry_actions(agent, runtime, mode:)
154
+ def self.build_entry_actions(agent, runtime, mode:, event_sink:)
151
155
  calling_action = if mode.to_sym == :stream
152
- method(:calling_llm_stream_action).curry.call(agent, runtime)
156
+ method(:calling_llm_stream_action).curry.call(agent, runtime, event_sink)
153
157
  else
154
- method(:calling_llm_action).curry.call(agent, runtime)
158
+ method(:calling_llm_action).curry.call(agent, runtime, event_sink)
155
159
  end
156
160
 
157
161
  {
158
- filtering_input: [
159
- method(:filtering_input_action).curry.call(agent)
160
- ],
161
- building_context: [
162
- method(:building_context_action).curry.call(agent)
163
- ],
162
+ filtering_input: [method(:filtering_input_action).curry.call(agent)],
163
+ building_context: [method(:building_context_action).curry.call(agent)],
164
164
  calling_llm: [calling_action],
165
- starting_tools: [
166
- method(:starting_tools_action).curry.call(runtime)
167
- ],
168
- dispatching_tools: [
169
- method(:dispatching_tools_action).curry.call(runtime)
170
- ],
171
- recording_tool_results: [
172
- method(:recording_tool_results_action)
173
- ],
165
+ starting_tools: [method(:starting_tools_action).curry.call(runtime, event_sink)],
166
+ dispatching_tools: [method(:dispatching_tools_action).curry.call(runtime, event_sink)],
167
+ recording_tool_results: [method(:recording_tool_results_action)],
174
168
  suspended: [method(:suspended_action)],
175
- output_filtering: [
176
- method(:output_filtering_action).curry.call(agent)
177
- ],
169
+ output_filtering: [method(:output_filtering_action).curry.call(agent)],
178
170
  failed: [method(:failed_action)]
179
171
  }
180
172
  end
@@ -195,15 +187,11 @@ module Phronomy
195
187
  projection,
196
188
  invocation: invocation
197
189
  )
198
- install_tool_interceptors(invocation.chat, invocation)
199
190
  invocation
200
191
  end
201
192
  private_class_method :building_context_action
202
193
 
203
- # RubyLLM >= 1.15 adds the complete assistant Message to chat.messages
204
- # before before_tool_call is fired. Capture that Message at the control
205
- # boundary instead of trying to recover it after the exception unwinds.
206
- def self.install_tool_interceptors(chat, invocation = nil)
194
+ def self.install_tool_interceptors(chat, llm_call_id:)
207
195
  unless chat.respond_to?(:before_tool_call)
208
196
  raise Phronomy::ConfigurationError,
209
197
  "Agent-owned Tool execution requires RubyLLM >= 1.15 (before_tool_call callback)"
@@ -211,17 +199,17 @@ module Phronomy
211
199
 
212
200
  if chat.respond_to?(:on_tool_call_batch)
213
201
  chat.on_tool_call_batch do |tool_calls|
214
- raise build_tool_interception(chat, tool_calls, invocation)
202
+ raise build_tool_interception(chat, tool_calls, llm_call_id)
215
203
  end
216
204
  end
217
205
 
218
206
  chat.before_tool_call do |tool_call|
219
- raise build_tool_interception(chat, [tool_call], invocation)
207
+ raise build_tool_interception(chat, [tool_call], llm_call_id)
220
208
  end
221
209
  end
222
210
  private_class_method :install_tool_interceptors
223
211
 
224
- def self.build_tool_interception(chat, fallback_tool_calls, invocation)
212
+ def self.build_tool_interception(chat, fallback_tool_calls, llm_call_id)
225
213
  assistant_message = chat.messages.last
226
214
  unless assistant_message&.respond_to?(:role) &&
227
215
  assistant_message.role.to_sym == :assistant &&
@@ -231,94 +219,88 @@ module Phronomy
231
219
  end
232
220
 
233
221
  message_tool_calls = assistant_message.tool_calls
234
- tool_calls = if message_tool_calls.respond_to?(:values)
235
- message_tool_calls.values
236
- else
237
- Array(message_tool_calls)
238
- end
222
+ tool_calls = message_tool_calls.respond_to?(:values) ? message_tool_calls.values : Array(message_tool_calls)
239
223
  tool_calls = Array(fallback_tool_calls) if tool_calls.empty?
240
224
 
241
225
  ToolCallIntercepted.new(
242
226
  tool_calls,
243
227
  assistant_message: assistant_message,
244
228
  assistant_outcome: ProviderCallOutcome.capture(assistant_message),
245
- llm_call_id: invocation&.current_llm_call_id
229
+ llm_call_id: llm_call_id
246
230
  )
247
231
  end
248
232
  private_class_method :build_tool_interception
249
233
 
250
- def self.calling_llm_action(agent, runtime, invocation)
251
- prepare_and_start_llm_call(
252
- agent,
253
- runtime,
254
- invocation,
255
- streaming: false
256
- )
234
+ def self.calling_llm_action(agent, runtime, event_sink, invocation)
235
+ prepare_and_start_llm_call(agent, runtime, event_sink, invocation, streaming: false)
257
236
  invocation
258
237
  end
259
238
  private_class_method :calling_llm_action
260
239
 
261
- def self.calling_llm_stream_action(agent, runtime, invocation)
262
- prepare_and_start_llm_call(
263
- agent,
264
- runtime,
265
- invocation,
266
- streaming: true
267
- )
240
+ def self.calling_llm_stream_action(agent, runtime, event_sink, invocation)
241
+ prepare_and_start_llm_call(agent, runtime, event_sink, invocation, streaming: true)
268
242
  invocation
269
243
  end
270
244
  private_class_method :calling_llm_stream_action
271
245
 
272
- def self.prepare_and_start_llm_call(agent, runtime, invocation, streaming:)
273
- activation = invocation.config.fetch(:phronomy_activation)
246
+ def self.prepare_and_start_llm_call(agent, runtime, event_sink, invocation, streaming:)
247
+ # Callback failure is recorded synchronously on EventLoop before its
248
+ # explicit failure event is queued. Do not start a competing durable
249
+ # follow-up operation from the same execution revision while that failure
250
+ # event is waiting to terminalize the FSMSession.
251
+ return if invocation.callback_failed?
252
+
274
253
  if invocation.user_message_sent
275
- preparation = runtime.offload.submit(on_full: :raise) do
276
- activation.coordinator.prepare_next_llm_call(activation)
277
- end
278
- preparation.on_complete do |projection, error|
279
- if error
280
- post_preparation_failure(runtime, invocation, error, streaming: streaming)
281
- else
282
- start_provider_call(
283
- agent,
284
- runtime,
285
- invocation,
286
- activation,
287
- projection,
288
- streaming: streaming,
289
- replace_messages: true
290
- )
291
- end
292
- end
254
+ invocation.config.fetch(:phronomy_execution_coordinator).prepare_provider_dispatch(
255
+ invocation,
256
+ event_sink: event_sink,
257
+ streaming: streaming
258
+ )
293
259
  else
294
260
  start_provider_call(
295
261
  agent,
296
262
  runtime,
263
+ event_sink,
297
264
  invocation,
298
- activation,
299
- activation.runtime_projection,
265
+ invocation.config.fetch(:phronomy_runtime_projection),
300
266
  streaming: streaming,
301
267
  replace_messages: false
302
268
  )
303
269
  end
270
+ rescue => error
271
+ post_setup_failure(event_sink, error)
304
272
  end
305
273
  private_class_method :prepare_and_start_llm_call
306
274
 
307
- def self.start_provider_call(
308
- agent,
309
- runtime,
310
- invocation,
311
- activation,
312
- projection,
313
- streaming:,
314
- replace_messages:
275
+ # Continues a follow-up Provider Call after its durable preparation result
276
+ # has been validated/applied by ExecutionCoordinator on EventLoop.
277
+ # @api private
278
+ def self.start_prepared_provider_call(
279
+ agent:,
280
+ runtime:,
281
+ event_sink:,
282
+ invocation:,
283
+ projection:,
284
+ streaming:
315
285
  )
316
- call_started = false
317
- agent.send(
318
- :check_cancellation!,
319
- invocation.config,
320
- "invocation cancelled before LLM call"
286
+ start_provider_call(
287
+ agent,
288
+ runtime,
289
+ event_sink,
290
+ invocation,
291
+ projection,
292
+ streaming: streaming,
293
+ replace_messages: true
321
294
  )
295
+ end
296
+
297
+ def self.start_provider_call(
298
+ agent, runtime, event_sink, invocation, projection,
299
+ streaming:, replace_messages:
300
+ )
301
+ call_context = trace_handle = nil
302
+ config = invocation.config
303
+ agent.send(:check_cancellation!, config, "invocation cancelled before LLM call")
322
304
  if replace_messages
323
305
  invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
324
306
  agent.send(
@@ -327,69 +309,83 @@ module Phronomy
327
309
  projection,
328
310
  invocation: invocation
329
311
  )
330
- install_tool_interceptors(invocation.chat, invocation)
331
312
  invocation.config[:phronomy_runtime_projection] = projection
332
313
  end
333
314
 
334
- call_context = activation.begin_llm_call(projection)
335
- call_started = true
336
- invocation.begin_llm_call!(call_context.fetch(:llm_call_id))
315
+ call_context = invocation.begin_llm_call!(projection)
316
+ install_tool_interceptors(
317
+ invocation.chat,
318
+ llm_call_id: call_context.fetch(:llm_call_id)
319
+ )
337
320
  message = projection.ask_message
321
+ chat = invocation.chat
322
+ trace_handle = Phronomy::Tracing::Automatic.start(
323
+ "llm.call",
324
+ input: message,
325
+ agent_id: agent.agent_id,
326
+ execution_id: invocation.execution_id,
327
+ llm_call_id: call_context.fetch(:llm_call_id),
328
+ mode: invocation.mode,
329
+ streaming: streaming,
330
+ **agent.send(:_build_caller_meta, config)
331
+ )
338
332
 
339
333
  operation = if streaming
340
334
  Phronomy.configuration.llm_adapter.stream_async(
341
- invocation.chat,
342
- message,
343
- config: invocation.config
335
+ chat, message, config: config
344
336
  ) do |chunk|
345
- agent.send(
346
- :check_cancellation!,
347
- invocation.config,
348
- "invocation cancelled during streaming"
349
- )
350
- post_to_invocation!(
351
- runtime,
352
- invocation.id,
353
- :llm_stream_chunk,
354
- {content: chunk.content}
337
+ token = config[:cancellation_token]
338
+ token&.raise_if_cancelled!("invocation cancelled during streaming")
339
+ post_stream_chunk(
340
+ event_sink,
341
+ call_context.fetch(:llm_call_id),
342
+ chunk.content
355
343
  )
356
344
  end
357
345
  else
358
346
  Phronomy.configuration.llm_adapter.complete_async(
359
- invocation.chat,
360
- message,
361
- config: invocation.config
347
+ chat, message, config: config
362
348
  )
363
349
  end
350
+ runtime.event_loop.supervise_agent_operation(
351
+ invocation.execution_id,
352
+ operation
353
+ )
364
354
  observe_manifest_call(
365
355
  operation,
366
- activation,
367
- invocation,
368
- runtime: runtime,
356
+ event_sink,
357
+ call_context,
358
+ trace_handle,
369
359
  streaming: streaming
370
360
  )
371
361
  rescue => error
372
- if call_started
373
- activation.record_llm_result(
374
- response: canonical_response_for(nil, error),
375
- error: error,
362
+ finish_provider_trace(trace_handle, nil, error)
363
+ if call_context
364
+ post_llm_result(
365
+ event_sink,
366
+ call_context,
367
+ nil,
368
+ error,
376
369
  streaming: streaming
377
370
  )
371
+ else
372
+ post_setup_failure(event_sink, error)
378
373
  end
379
- post_llm_result(runtime, invocation, nil, error, streaming: streaming)
380
374
  end
381
375
  private_class_method :start_provider_call
382
376
 
383
- def self.observe_manifest_call(operation, activation, invocation, runtime:, streaming:)
377
+ def self.observe_manifest_call(
378
+ operation,
379
+ event_sink,
380
+ call_context,
381
+ trace_handle,
382
+ streaming:
383
+ )
384
384
  operation.on_complete do |response, error|
385
- activation.record_llm_result(
386
- response: canonical_response_for(response, error),
387
- error: error,
388
- streaming: streaming
389
- )
385
+ finish_provider_trace(trace_handle, response, error)
390
386
  post_llm_result(
391
- runtime,
392
- invocation,
387
+ event_sink,
388
+ call_context,
393
389
  response,
394
390
  error,
395
391
  streaming: streaming
@@ -398,22 +394,27 @@ module Phronomy
398
394
  end
399
395
  private_class_method :observe_manifest_call
400
396
 
401
- def self.canonical_response_for(response, error)
402
- if error.is_a?(ToolCallIntercepted)
403
- error.assistant_outcome || ProviderCallOutcome.capture(response)
404
- else
405
- ProviderCallOutcome.capture(response)
397
+ def self.finish_provider_trace(trace_handle, response, error)
398
+ control_transfer = error.is_a?(ToolCallIntercepted)
399
+ trace_output = control_transfer ?
400
+ (error.assistant_outcome || error.assistant_message) : response
401
+ trace_error = control_transfer ? nil : error
402
+ usage_source = control_transfer ? error.assistant_message : response
403
+ usage = if usage_source&.respond_to?(:tokens)
404
+ Phronomy::TokenUsage.from_tokens(usage_source.tokens)
406
405
  end
406
+ Phronomy::Tracing::Automatic.finish(
407
+ trace_handle,
408
+ output: trace_output,
409
+ usage: usage,
410
+ error: trace_error
411
+ )
407
412
  end
408
- private_class_method :canonical_response_for
409
-
410
- def self.post_preparation_failure(runtime, invocation, error, streaming:)
411
- post_llm_result(runtime, invocation, nil, error, streaming: streaming)
412
- end
413
- private_class_method :post_preparation_failure
413
+ private_class_method :finish_provider_trace
414
414
 
415
- def self.post_llm_result(runtime, invocation, response, error, streaming:)
415
+ def self.post_llm_result(event_sink, call_context, response, error, streaming:)
416
416
  result = LLMOperationResult.new(
417
+ llm_call_id: call_context.fetch(:llm_call_id),
417
418
  response: response,
418
419
  error: error,
419
420
  streaming: streaming
@@ -423,46 +424,65 @@ module Phronomy
423
424
  else
424
425
  :llm_completed
425
426
  end
426
- post_to_invocation!(runtime, invocation.id, event_type, result)
427
+ post_session_event!(event_sink, event_type, result)
427
428
  end
428
429
  private_class_method :post_llm_result
429
430
 
430
- def self.post_to_invocation!(runtime, invocation_id, event_type, payload)
431
- accepted = runtime.event_loop.post_to_session(
432
- Phronomy::Event.new(
433
- type: event_type,
434
- target_id: invocation_id,
435
- payload: payload
436
- )
431
+ def self.post_stream_chunk(event_sink, llm_call_id, content)
432
+ post_session_event!(
433
+ event_sink,
434
+ :llm_stream_chunk,
435
+ {llm_call_id: llm_call_id.to_s.freeze, content: content}.freeze
437
436
  )
438
- return if accepted
437
+ end
438
+ private_class_method :post_stream_chunk
439
+
440
+ def self.post_setup_failure(event_sink, error)
441
+ post_session_event!(event_sink, :llm_setup_failed, error)
442
+ end
443
+ private_class_method :post_setup_failure
444
+
445
+ def self.post_session_event!(event_sink, event_type, payload)
446
+ return if event_sink.post(event_type, payload)
439
447
 
440
448
  Phronomy.configuration.logger&.warn(
441
449
  "[Phronomy] Dropped late #{event_type.inspect} for " \
442
- "AgentInvocation #{invocation_id}"
450
+ "FSMSession #{event_sink.fsm_session_id}"
443
451
  )
444
452
  end
445
- private_class_method :post_to_invocation!
453
+ private_class_method :post_session_event!
446
454
 
447
- def self.starting_tools_action(runtime, invocation)
455
+ def self.starting_tools_action(runtime, parent_event_sink, invocation)
448
456
  children = invocation.pending_tool_calls.map do |tool_call|
449
457
  tool = invocation.chat.tools[tool_call.name.to_sym]
458
+ tool_invocation_id =
459
+ RecoverySupport.semantic_tool_id(
460
+ execution_id: invocation.execution_id,
461
+ llm_call_id: invocation.tool_batch_llm_call_id,
462
+ tool_call_id: (
463
+ tool_call.respond_to?(:id) ? tool_call.id : nil
464
+ ),
465
+ tool_name: tool_call.name
466
+ )
467
+
450
468
  if tool
451
469
  ToolInvocation.new(
452
- parent_agent_invocation_id: invocation.id,
470
+ execution_id: invocation.execution_id,
453
471
  agent: invocation.agent,
454
472
  tool: tool,
455
473
  tool_call: tool_call,
456
474
  config: invocation.config,
457
475
  approval_policy: invocation.approval_policy,
458
- approval_context: invocation.approval_context
476
+ approval_context: invocation.approval_context,
477
+ id: tool_invocation_id
459
478
  )
460
479
  else
461
480
  ToolInvocation.missing(
462
- parent_agent_invocation_id: invocation.id,
481
+ execution_id: invocation.execution_id,
463
482
  agent: invocation.agent,
464
483
  tool_call: tool_call,
465
- config: invocation.config
484
+ config: invocation.config,
485
+ id: tool_invocation_id
466
486
  )
467
487
  end
468
488
  end
@@ -471,45 +491,65 @@ module Phronomy
471
491
  children.reject(&:terminal?).each do |child|
472
492
  session = ToolInvocationSessionBuilder.build(
473
493
  tool_invocation: child,
494
+ parent_event_sink: parent_event_sink,
474
495
  runtime: runtime
475
496
  )
476
- register_child_session(runtime, child, session)
497
+ register_child_session(
498
+ runtime,
499
+ child,
500
+ session,
501
+ parent_event_sink
502
+ )
477
503
  end
478
504
  invocation
479
505
  end
480
506
  private_class_method :starting_tools_action
481
507
 
482
- def self.dispatching_tools_action(runtime, invocation)
483
- invocation.tool_invocations
484
- .select(&:authorized?)
485
- .each do |child|
486
- session = ToolInvocationSessionBuilder.build_for_resume(
487
- tool_invocation: child,
488
- resume_event: :dispatch,
489
- resume_phase: :authorized,
490
- runtime: runtime
491
- )
492
- register_child_session(runtime, child, session)
493
- end
508
+ def self.dispatching_tools_action(_runtime, parent_event_sink, invocation)
509
+ invocation.config.fetch(:phronomy_execution_coordinator).prepare_tool_dispatch(
510
+ invocation,
511
+ event_sink: parent_event_sink
512
+ )
494
513
  invocation
495
514
  end
496
515
  private_class_method :dispatching_tools_action
497
516
 
498
- def self.register_child_session(runtime, child, session)
499
- completion = Phronomy::Task.deferred(
500
- name: "tool-session:#{child.id}"
501
- )
517
+ # Continues Tool physical dispatch after the operation-specific durable
518
+ # preparation has been confirmed and applied by ExecutionCoordinator.
519
+ # @api private
520
+ def self.start_prepared_tool_dispatch(
521
+ runtime:,
522
+ event_sink:,
523
+ invocation:
524
+ )
525
+ invocation.tool_invocations.select(&:authorized?).each do |child|
526
+ session = ToolInvocationSessionBuilder.build_for_resume(
527
+ tool_invocation: child,
528
+ parent_event_sink: event_sink,
529
+ resume_event: :dispatch,
530
+ resume_phase: :authorized,
531
+ runtime: runtime
532
+ )
533
+ begin
534
+ register_child_session(runtime, child, session, event_sink)
535
+ rescue => error
536
+ child.mark_framework_failed!(error)
537
+ event_sink.post(:tool_failed, {tool_invocation_id: child.id})
538
+ end
539
+ end
540
+ post_session_event!(event_sink, :tool_dispatch_prepared, nil)
541
+ invocation
542
+ end
543
+
544
+ def self.register_child_session(runtime, child, session, parent_event_sink)
545
+ completion = Phronomy::Task.deferred(name: "tool-session:#{child.id}")
502
546
  completion.on_complete do |_result, error|
503
547
  next unless error
504
548
 
549
+ # Tool FSMSession completion is settled by EventLoop, so this callback
550
+ # also executes on EventLoop and remains inside the single-writer domain.
505
551
  child.mark_framework_failed!(error)
506
- runtime.event_loop.post_to_session(
507
- Phronomy::Event.new(
508
- type: :tool_failed,
509
- target_id: child.parent_agent_invocation_id,
510
- payload: {tool_invocation_id: child.id}
511
- )
512
- )
552
+ parent_event_sink.post(:tool_failed, {tool_invocation_id: child.id})
513
553
  end
514
554
  runtime.event_loop.register(session, completion: completion)
515
555
  end
@@ -526,18 +566,12 @@ module Phronomy
526
566
  private_class_method :suspended_action
527
567
 
528
568
  def self.failed_action(invocation)
529
- raise(
530
- invocation.error ||
531
- Phronomy::ToolError.new("Agent invocation failed")
532
- )
569
+ raise(invocation.error || Phronomy::ToolError.new("Agent invocation failed"))
533
570
  end
534
571
  private_class_method :failed_action
535
572
 
536
573
  def self.output_filtering_action(agent, invocation)
537
- invocation.output = agent.send(
538
- :run_output_filters!,
539
- invocation.output
540
- )
574
+ invocation.output = agent.send(:run_output_filters!, invocation.output)
541
575
  invocation
542
576
  rescue Phronomy::FilterBlockError => error
543
577
  invocation.output_blocked = true