phronomy 0.22.0 → 0.23.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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -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 +152 -0
  15. data/docs/architecture/persistence.md +175 -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 +82 -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 +112 -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/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -3,21 +3,15 @@
3
3
  require_relative "runtime/timer_queue"
4
4
  require_relative "runtime/shutdown_result"
5
5
  require_relative "runtime/timer_service"
6
+ require_relative "runtime/agent_ownership_registry"
6
7
 
7
8
  module Phronomy
8
- # Owns the EventLoop, offloaded synchronous work, timers and shutdown lifecycle.
9
- #
10
- # Runtime no longer schedules arbitrary Tasks. Framework control flow belongs
11
- # to EventLoop/FSMSession; synchronous work that must not run on the EventLoop
12
- # belongs to OffloadPool.
13
9
  class Runtime
14
10
  @instance_mutex = Mutex.new
15
11
 
16
12
  class << self
17
13
  def instance
18
- instance_mutex.synchronize do
19
- @instance ||= new
20
- end
14
+ instance_mutex.synchronize { @instance ||= new }
21
15
  end
22
16
 
23
17
  def default_if_initialized_for_test
@@ -69,7 +63,8 @@ module Phronomy
69
63
  @pool_registry = Phronomy::Concurrency::PoolRegistry.new(
70
64
  timer_queue_provider: -> { timer_queue }
71
65
  )
72
- @agent_activations = Phronomy::Agent::ActivationRegistry.new
66
+ @multi_agent_admissions = Phronomy::MultiAgent::AdmissionRegistry.new
67
+ @agent_ownership_registry = AgentOwnershipRegistry.new(runtime: self)
73
68
  @lifecycle_mutex = Mutex.new
74
69
  @shutdown_mutex = Mutex.new
75
70
  @state = :running
@@ -95,7 +90,6 @@ module Phronomy
95
90
  @pool_registry.named_pool(name, size: size, queue_size: queue_size)
96
91
  end
97
92
 
98
- # Public timer access also ensures that the EventLoop that drives timers is alive.
99
93
  def timer_queue
100
94
  ensure_accepting_work!
101
95
  timer = @timer_service.timer_queue
@@ -103,15 +97,77 @@ module Phronomy
103
97
  timer
104
98
  end
105
99
 
106
- # Internal EventLoop access that does not recursively initialise EventLoop.
107
100
  def __timer_queue
108
101
  @timer_service.timer_queue
109
102
  end
110
103
 
111
- # Process-local live Agent executions. Activations are transient runtime
112
- # state and deliberately do not belong to Persistence.
113
- def __agent_activations
114
- @agent_activations
104
+ # Returns only an immutable process-local routing/ownership view. Mutable
105
+ # execution state remains inside EventLoop and is never exposed to callers.
106
+ # @api private
107
+ def __agent_execution_owner(execution_id)
108
+ loop_instance = @lifecycle_mutex.synchronize { @event_loop }
109
+ loop_instance&.agent_execution_owner(execution_id)
110
+ end
111
+
112
+ # @api private
113
+ def __create_agent(agent_id, expected_class:, &block)
114
+ @agent_ownership_registry.create(agent_id, expected_class: expected_class, &block)
115
+ end
116
+
117
+ # @api private
118
+ def __load_agent(agent_id, expected_class:, &block)
119
+ @agent_ownership_registry.load(agent_id, expected_class: expected_class, &block)
120
+ end
121
+
122
+ # @api private
123
+ def __get_agent(agent_id, expected_class:)
124
+ @agent_ownership_registry.get(agent_id, expected_class: expected_class)
125
+ end
126
+
127
+ # @api private
128
+ def __agent_owned?(agent)
129
+ @agent_ownership_registry.owned?(agent)
130
+ end
131
+
132
+ # @api private
133
+ def __begin_agent_purge(agent)
134
+ @agent_ownership_registry.begin_purge(agent)
135
+ end
136
+
137
+ # @api private
138
+ def __complete_agent_purge(agent, token)
139
+ @agent_ownership_registry.complete_purge(agent, token)
140
+ end
141
+
142
+ # @api private
143
+ def __abort_agent_purge(agent, token)
144
+ @agent_ownership_registry.abort_purge(agent, token)
145
+ end
146
+
147
+ # @api private
148
+ def __leave_agent_purge_uncertain(agent, token)
149
+ @agent_ownership_registry.leave_purge_uncertain(agent, token)
150
+ end
151
+
152
+ # @api private
153
+ def __agent_execution_admitted?(agent_id)
154
+ loop_instance = @lifecycle_mutex.synchronize { @event_loop }
155
+ loop_instance&.agent_execution_admitted?(agent_id) || false
156
+ end
157
+
158
+ # @api private
159
+ def __admit_multi_agent(coordinator)
160
+ current_state = @lifecycle_mutex.synchronize { @state }
161
+ unless current_state == :running
162
+ raise Phronomy::RuntimeShutdownError,
163
+ "Runtime is #{current_state}; new Multi-Agent turns are not accepted"
164
+ end
165
+ @multi_agent_admissions.admit!(coordinator)
166
+ end
167
+
168
+ # @api private
169
+ def __release_multi_agent(coordinator)
170
+ @multi_agent_admissions.release!(coordinator)
115
171
  end
116
172
 
117
173
  def event_loop
@@ -163,23 +219,22 @@ module Phronomy
163
219
  @shutdown_mutex.synchronize do
164
220
  return @shutdown_result if @shutdown_result
165
221
 
166
- # Phase 1 — drain sessions with the full configured grace.
167
222
  drain_deadline = monotonic_now + timeout
168
223
  loop_instance = @lifecycle_mutex.synchronize do
169
224
  @state = :draining unless @state == :failed
170
225
  @event_loop
171
226
  end
172
227
  loop_instance&.begin_draining
228
+ @agent_ownership_registry.begin_draining
173
229
 
230
+ admission_idle = @multi_agent_admissions.wait_until_idle(drain_deadline)
231
+ agent_ownership_stable = @agent_ownership_registry.wait_until_stable(drain_deadline)
174
232
  loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
175
233
 
176
234
  @lifecycle_mutex.synchronize do
177
235
  @state = :stopping unless @state == :failed
178
236
  end
179
237
 
180
- # Phase 2 — stop the EventLoop thread with a short independent budget.
181
- # An idle EventLoop processes STOP and exits in < 1ms normally; the 0.2s
182
- # budget here is only a safety net for OS scheduling jitter.
183
238
  stop_deadline = monotonic_now + [cancel_grace.to_f, 0.2].max
184
239
  event_loop_status = if loop_instance
185
240
  loop_instance.stop_and_join(deadline: stop_deadline)
@@ -188,7 +243,7 @@ module Phronomy
188
243
  end
189
244
 
190
245
  subsystem_error = shutdown_pools_and_timer
191
- cleanup_complete = loop_idle &&
246
+ cleanup_complete = admission_idle && agent_ownership_stable && loop_idle &&
192
247
  (!loop_instance || !loop_instance.thread_alive?) &&
193
248
  event_loop_status != :cancel_timeout &&
194
249
  subsystem_error.nil?
@@ -200,6 +255,8 @@ module Phronomy
200
255
  :terminated
201
256
  end
202
257
 
258
+ @agent_ownership_registry.shutdown! if cleanup_complete
259
+
203
260
  result = ShutdownResult.new(
204
261
  runtime_outcome: runtime_outcome,
205
262
  cleanup_status: cleanup_complete ? :complete : :incomplete,
@@ -159,7 +159,7 @@ module Phronomy
159
159
  feedback = next_state.review_notes.last
160
160
  prompt = draft_prompt_builder.call(next_state.input, feedback)
161
161
 
162
- draft_agent.invoke_async(
162
+ draft_agent.send(:__invoke_async_with_event_sink,
163
163
  prompt,
164
164
  on_event: ->(agent_event) {
165
165
  case agent_event.type
@@ -169,7 +169,7 @@ module Phronomy
169
169
  agent_event.payload[:output]
170
170
  )
171
171
  workflow.signal(
172
- thread_id: next_state.thread_id,
172
+ workflow_instance_id: next_state.workflow_instance_id,
173
173
  event: :draft_completed,
174
174
  payload: {
175
175
  request_id: request_id,
@@ -186,7 +186,7 @@ module Phronomy
186
186
  )
187
187
  rescue => error
188
188
  workflow.signal(
189
- thread_id: next_state.thread_id,
189
+ workflow_instance_id: next_state.workflow_instance_id,
190
190
  event: :draft_failed,
191
191
  payload: {
192
192
  request_id: request_id,
@@ -196,7 +196,7 @@ module Phronomy
196
196
  end
197
197
  when :error, :timeout, :cancelled
198
198
  workflow.signal(
199
- thread_id: next_state.thread_id,
199
+ workflow_instance_id: next_state.workflow_instance_id,
200
200
  event: :draft_failed,
201
201
  payload: {
202
202
  request_id: request_id,
@@ -209,7 +209,7 @@ module Phronomy
209
209
  )
210
210
  when :approval_required
211
211
  workflow.signal(
212
- thread_id: next_state.thread_id,
212
+ workflow_instance_id: next_state.workflow_instance_id,
213
213
  event: :draft_failed,
214
214
  payload: {
215
215
  request_id: request_id,
@@ -219,8 +219,7 @@ module Phronomy
219
219
  }
220
220
  )
221
221
  end
222
- }
223
- )
222
+ })
224
223
 
225
224
  next_state
226
225
  }
@@ -234,7 +233,7 @@ module Phronomy
234
233
  next_state.citations
235
234
  )
236
235
 
237
- review_agent.invoke_async(
236
+ review_agent.send(:__invoke_async_with_event_sink,
238
237
  prompt,
239
238
  on_event: ->(agent_event) {
240
239
  case agent_event.type
@@ -244,7 +243,7 @@ module Phronomy
244
243
  agent_event.payload[:output]
245
244
  )
246
245
  workflow.signal(
247
- thread_id: next_state.thread_id,
246
+ workflow_instance_id: next_state.workflow_instance_id,
248
247
  event: :review_completed,
249
248
  payload: {
250
249
  request_id: request_id,
@@ -258,7 +257,7 @@ module Phronomy
258
257
  )
259
258
  rescue => error
260
259
  workflow.signal(
261
- thread_id: next_state.thread_id,
260
+ workflow_instance_id: next_state.workflow_instance_id,
262
261
  event: :review_failed,
263
262
  payload: {
264
263
  request_id: request_id,
@@ -268,7 +267,7 @@ module Phronomy
268
267
  end
269
268
  when :error, :timeout, :cancelled
270
269
  workflow.signal(
271
- thread_id: next_state.thread_id,
270
+ workflow_instance_id: next_state.workflow_instance_id,
272
271
  event: :review_failed,
273
272
  payload: {
274
273
  request_id: request_id,
@@ -281,7 +280,7 @@ module Phronomy
281
280
  )
282
281
  when :approval_required
283
282
  workflow.signal(
284
- thread_id: next_state.thread_id,
283
+ workflow_instance_id: next_state.workflow_instance_id,
285
284
  event: :review_failed,
286
285
  payload: {
287
286
  request_id: request_id,
@@ -291,8 +290,7 @@ module Phronomy
291
290
  }
292
291
  )
293
292
  end
294
- }
295
- )
293
+ })
296
294
 
297
295
  next_state
298
296
  }
@@ -1,56 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Carries all per-invocation context values through the call stack.
4
+ # Carries per-invocation control, policy, and tracing context through the call stack.
5
5
  #
6
6
  # +InvocationContext+ is a plain struct-like value carrier that replaces
7
7
  # ad-hoc +Thread.current[...]+ propagation.
8
8
  # Pass it explicitly wherever context needs to cross a method boundary.
9
9
  #
10
+ # Generic conversation/session identity is deliberately not part of this
11
+ # object. Use purpose-specific domain identifiers and application tracing
12
+ # metadata instead.
13
+ #
10
14
  # @example Build a context for a new agent invocation
11
15
  # ctx = Phronomy::InvocationContext.new(
12
- # thread_id: "conv-123",
16
+ # task_id: "request-123",
13
17
  # cancellation_token: Phronomy::Concurrency::CancellationToken.timeout_after(30)
14
18
  # )
15
19
  # agent.invoke("Hello", invocation_context: ctx)
16
20
  #
17
21
  # @api public
18
22
  class InvocationContext
19
- attr_reader :thread_id, :session_id, :user_id, :cancellation_token,
20
- :deadline, :tracer_span, :token_budget, :approval_policy,
21
- :redaction_policy, :task_id, :parent_task_id
23
+ attr_reader :user_id, :cancellation_token, :deadline,
24
+ :token_budget, :approval_policy, :redaction_policy, :task_id,
25
+ :parent_task_id
22
26
 
23
- # @param thread_id [String, nil]
24
- # @param session_id [String, nil]
25
- # @param user_id [String, nil]
26
- # @param cancellation_token [CancellationToken, nil]
27
- # @param deadline [Deadline, nil]
28
- # @param tracer_span [Object, nil]
29
- # @param token_budget [Integer, nil]
30
- # @param approval_policy [#call, nil]
31
- # @param redaction_policy [Object, nil]
32
- # @param task_id [String, nil]
33
- # @param parent_task_id [String, nil]
34
27
  # @api public
35
28
  def initialize(
36
- thread_id: nil,
37
- session_id: nil,
38
29
  user_id: nil,
39
30
  cancellation_token: nil,
40
31
  deadline: nil,
41
- tracer_span: nil,
42
32
  token_budget: nil,
43
33
  approval_policy: nil,
44
34
  redaction_policy: nil,
45
35
  task_id: nil,
46
36
  parent_task_id: nil
47
37
  )
48
- @thread_id = thread_id
49
- @session_id = session_id
50
38
  @user_id = user_id
51
39
  @cancellation_token = cancellation_token
52
40
  @deadline = deadline
53
- @tracer_span = tracer_span
54
41
  @token_budget = token_budget
55
42
  @approval_policy = approval_policy
56
43
  @redaction_policy = redaction_policy
@@ -58,16 +45,12 @@ module Phronomy
58
45
  @parent_task_id = parent_task_id
59
46
  end
60
47
 
61
- # Returns a new +InvocationContext+ with the given attributes merged in.
62
48
  # @api private
63
49
  def merge(**overrides)
64
50
  InvocationContext.new(
65
- thread_id: overrides.fetch(:thread_id, @thread_id),
66
- session_id: overrides.fetch(:session_id, @session_id),
67
51
  user_id: overrides.fetch(:user_id, @user_id),
68
52
  cancellation_token: overrides.fetch(:cancellation_token, @cancellation_token),
69
53
  deadline: overrides.fetch(:deadline, @deadline),
70
- tracer_span: overrides.fetch(:tracer_span, @tracer_span),
71
54
  token_budget: overrides.fetch(:token_budget, @token_budget),
72
55
  approval_policy: overrides.fetch(:approval_policy, @approval_policy),
73
56
  redaction_policy: overrides.fetch(:redaction_policy, @redaction_policy),
@@ -76,14 +59,11 @@ module Phronomy
76
59
  )
77
60
  end
78
61
 
79
- # Convenience: returns the cancellation token or a new never-cancelled token.
80
62
  # @api private
81
63
  def effective_cancellation_token
82
64
  @cancellation_token || Phronomy::Concurrency::CancellationToken.new
83
65
  end
84
66
 
85
- # Returns the cancellation token to use for an invocation, taking both the
86
- # explicit cancellation_token and deadline into account.
87
67
  # @api private
88
68
  def effective_timeout_token
89
69
  return @cancellation_token if @cancellation_token
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ # Runtime-local admission for one user-facing coordination turn per main Agent.
6
+ # This owns exclusion only; active-agent mutation still belongs to EventLoop.
7
+ class AdmissionRegistry
8
+ def initialize
9
+ @mutex = Mutex.new
10
+ @cond = ConditionVariable.new
11
+ @owners = {}
12
+ end
13
+
14
+ def admit!(coordinator)
15
+ key = coordinator.object_id
16
+ @mutex.synchronize do
17
+ if @owners.key?(key)
18
+ raise Phronomy::HandoffError,
19
+ "a Multi-Agent turn is already active for this main Agent instance"
20
+ end
21
+ @owners[key] = coordinator
22
+ end
23
+ true
24
+ end
25
+
26
+ def release!(coordinator)
27
+ key = coordinator.object_id
28
+ @mutex.synchronize do
29
+ removed = @owners.delete(key)
30
+ @cond.broadcast if @owners.empty?
31
+ !removed.nil?
32
+ end
33
+ end
34
+
35
+ def idle?
36
+ @mutex.synchronize { @owners.empty? }
37
+ end
38
+
39
+ def wait_until_idle(deadline)
40
+ @mutex.synchronize do
41
+ until @owners.empty?
42
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ return false if remaining <= 0
44
+ @cond.wait(@mutex, remaining)
45
+ end
46
+ true
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ CoordinationState = Data.define(:active_agent, :active_handoff_context) do
6
+ def initialize(active_agent:, active_handoff_context: nil)
7
+ unless active_agent.is_a?(Phronomy::Agent::Base)
8
+ raise ArgumentError, "active_agent must be a Phronomy::Agent::Base"
9
+ end
10
+ if active_handoff_context && !active_handoff_context.is_a?(HandoffContext)
11
+ raise ArgumentError, "active_handoff_context must be a HandoffContext"
12
+ end
13
+ super
14
+ freeze
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class Coordinator
6
+ ATTACH_MUTEX = Mutex.new
7
+ private_constant :ATTACH_MUTEX
8
+
9
+ ApplyHandoffCommand = Data.define(:coordinator, :request, :context, :completion)
10
+
11
+ attr_reader :main_agent, :handoffs
12
+
13
+ def self.attach(main_agent:, handoffs:)
14
+ unless main_agent.is_a?(Phronomy::Agent::Base)
15
+ raise ArgumentError, "main_agent must be a Phronomy::Agent::Base"
16
+ end
17
+ normalized = Array(handoffs).freeze
18
+
19
+ ATTACH_MUTEX.synchronize do
20
+ existing = main_agent.instance_variable_get(:@_phronomy_multi_agent_coordinator)
21
+ if existing&.runtime_current?
22
+ existing.assert_compatible!(normalized)
23
+ return existing
24
+ end
25
+
26
+ created = new(main_agent: main_agent, handoffs: normalized)
27
+ main_agent.instance_variable_set(:@_phronomy_multi_agent_coordinator, created)
28
+ created
29
+ end
30
+ end
31
+
32
+ def initialize(main_agent:, handoffs:)
33
+ @main_agent = main_agent
34
+ @handoffs = Array(handoffs).freeze
35
+ @runtime = Phronomy::Runtime.instance
36
+ validate_graph!
37
+ @bindings_by_source = build_bindings
38
+ @state_mutex = Mutex.new
39
+ @state = CoordinationState.new(active_agent: main_agent)
40
+ end
41
+
42
+ def runtime_current?
43
+ Phronomy::Runtime.instance.equal?(@runtime)
44
+ end
45
+
46
+ def snapshot
47
+ unless runtime_current?
48
+ raise Phronomy::RuntimeShutdownError,
49
+ "Multi-Agent coordination state belongs to a previous Runtime"
50
+ end
51
+ @state_mutex.synchronize { @state }
52
+ end
53
+
54
+ def outgoing_bindings(agent)
55
+ @bindings_by_source.fetch(agent.object_id, []).freeze
56
+ end
57
+
58
+ def transition!(request, context)
59
+ completion = Phronomy::Task.deferred(name: "multi-agent-handoff")
60
+ command = ApplyHandoffCommand.new(
61
+ coordinator: self,
62
+ request: request,
63
+ context: context,
64
+ completion: completion
65
+ )
66
+ posted = Phronomy::Runtime.instance.event_loop.post(
67
+ Phronomy::Event.new(
68
+ type: :agent_terminal_ready,
69
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
70
+ payload: {command: command}
71
+ )
72
+ )
73
+ unless posted
74
+ completion.fail(
75
+ Phronomy::RuntimeShutdownError.new(
76
+ "EventLoop is not accepting Multi-Agent Handoff transitions"
77
+ )
78
+ )
79
+ end
80
+ completion.wait_result
81
+ end
82
+
83
+ # @api private
84
+ def deliver_on_event_loop(command)
85
+ runtime = Phronomy::Runtime.instance
86
+ unless runtime.event_loop.current?
87
+ raise Phronomy::Error,
88
+ "Multi-Agent coordination state may only be mutated on EventLoop"
89
+ end
90
+
91
+ request = command.request
92
+ context = command.context
93
+ current = snapshot
94
+ unless request.handoff.source_agent.equal?(current.active_agent)
95
+ raise Phronomy::HandoffError,
96
+ "Handoff source is no longer the active Agent"
97
+ end
98
+
99
+ next_state = CoordinationState.new(
100
+ active_agent: request.handoff.target_agent,
101
+ active_handoff_context: context
102
+ )
103
+ @state_mutex.synchronize { @state = next_state }
104
+ command.completion.complete(next_state)
105
+ rescue => error
106
+ command.completion.fail(error)
107
+ end
108
+
109
+ def assert_compatible!(handoffs)
110
+ incoming = graph_signature(handoffs)
111
+ current = graph_signature(@handoffs)
112
+ return true if incoming == current
113
+
114
+ raise Phronomy::ConfigurationError,
115
+ "a MultiAgent::Runner for this main Agent already exists with a different Handoff graph"
116
+ end
117
+
118
+ private
119
+
120
+ def validate_graph!
121
+ unless @handoffs.all? { |handoff| handoff.is_a?(Handoff) }
122
+ raise ArgumentError, "handoffs must contain only MultiAgent::Handoff values"
123
+ end
124
+
125
+ duplicates = @handoffs.group_by do |handoff|
126
+ [handoff.source_agent.object_id, handoff.target_agent.object_id]
127
+ end.select { |_key, values| values.length > 1 }
128
+ unless duplicates.empty?
129
+ raise ArgumentError, "duplicate Source → Target Handoff edges are not allowed"
130
+ end
131
+ end
132
+
133
+ def build_bindings
134
+ @handoffs.group_by(&:source_agent).to_h do |source, edges|
135
+ [
136
+ source.object_id,
137
+ edges.map { |handoff| HandoffCapabilityFactory.build(handoff) }.freeze
138
+ ]
139
+ end.freeze
140
+ end
141
+
142
+ def graph_signature(handoffs)
143
+ Array(handoffs).map do |handoff|
144
+ [
145
+ handoff.source_agent.object_id,
146
+ handoff.target_agent.object_id,
147
+ handoff.policy.to_h,
148
+ handoff.description
149
+ ]
150
+ end.sort_by { |row| [row[0], row[1], row[3]] }
151
+ end
152
+ end
153
+ end
154
+ end