phronomy 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +147 -0
  15. data/docs/architecture/persistence.md +186 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +89 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. data/lib/phronomy/multi_agent/handoff.rb +0 -64
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Runtime-local Team identity and construction exclusion; no execution state.
6
+ # @api private
7
+ class TeamOwnershipRegistry
8
+ def initialize
9
+ @mutex = Mutex.new
10
+ @condition = ConditionVariable.new
11
+ @owners = {}
12
+ @constructing = {}
13
+ @draining = false
14
+ end
15
+
16
+ def fetch(id, klass:, create:, persistence:)
17
+ @mutex.synchronize do
18
+ loop do
19
+ raise Phronomy::RuntimeShutdownError, "Runtime is draining" if @draining
20
+ if (owner = @owners[id])
21
+ raise Phronomy::Persistence::ConflictError, "Team #{id} already exists" if create
22
+ unless owner.is_a?(klass) && owner.persistence.equal?(persistence)
23
+ raise Phronomy::ConfigurationError, "Team #{id} owner or Persistence mismatch"
24
+ end
25
+ return owner
26
+ end
27
+ break unless @constructing[id]
28
+ @condition.wait(@mutex)
29
+ end
30
+ @constructing[id] = true
31
+ end
32
+ begin
33
+ owner = yield
34
+ @mutex.synchronize { @owners[id] = owner }
35
+ owner
36
+ ensure
37
+ @mutex.synchronize do
38
+ @constructing.delete(id)
39
+ @condition.broadcast
40
+ end
41
+ end
42
+ end
43
+
44
+ def get(id, klass:)
45
+ @mutex.synchronize do
46
+ owner = @owners[id]
47
+ if owner && !owner.is_a?(klass)
48
+ raise Phronomy::ConfigurationError, "Team #{id} definition mismatch"
49
+ end
50
+ owner
51
+ end
52
+ end
53
+
54
+ def begin_draining
55
+ @mutex.synchronize do
56
+ @draining = true
57
+ @condition.broadcast
58
+ end
59
+ end
60
+
61
+ def wait_until_stable(deadline)
62
+ @mutex.synchronize do
63
+ until @constructing.empty?
64
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ return false if remaining <= 0
66
+ @condition.wait(@mutex, remaining)
67
+ end
68
+ true
69
+ end
70
+ end
71
+
72
+ def shutdown!
73
+ @mutex.synchronize { @owners.clear }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -3,21 +3,16 @@
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"
7
+ require_relative "runtime/team_ownership_registry"
6
8
 
7
9
  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
10
  class Runtime
14
11
  @instance_mutex = Mutex.new
15
12
 
16
13
  class << self
17
14
  def instance
18
- instance_mutex.synchronize do
19
- @instance ||= new
20
- end
15
+ instance_mutex.synchronize { @instance ||= new }
21
16
  end
22
17
 
23
18
  def default_if_initialized_for_test
@@ -69,7 +64,9 @@ module Phronomy
69
64
  @pool_registry = Phronomy::Concurrency::PoolRegistry.new(
70
65
  timer_queue_provider: -> { timer_queue }
71
66
  )
72
- @agent_activations = Phronomy::Agent::ActivationRegistry.new
67
+ @multi_agent_admissions = Phronomy::MultiAgent::AdmissionRegistry.new
68
+ @agent_ownership_registry = AgentOwnershipRegistry.new(runtime: self)
69
+ @team_ownership_registry = TeamOwnershipRegistry.new
73
70
  @lifecycle_mutex = Mutex.new
74
71
  @shutdown_mutex = Mutex.new
75
72
  @state = :running
@@ -95,7 +92,6 @@ module Phronomy
95
92
  @pool_registry.named_pool(name, size: size, queue_size: queue_size)
96
93
  end
97
94
 
98
- # Public timer access also ensures that the EventLoop that drives timers is alive.
99
95
  def timer_queue
100
96
  ensure_accepting_work!
101
97
  timer = @timer_service.timer_queue
@@ -103,15 +99,87 @@ module Phronomy
103
99
  timer
104
100
  end
105
101
 
106
- # Internal EventLoop access that does not recursively initialise EventLoop.
107
102
  def __timer_queue
108
103
  @timer_service.timer_queue
109
104
  end
110
105
 
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
106
+ # Returns only an immutable process-local routing/ownership view. Mutable
107
+ # execution state remains inside EventLoop and is never exposed to callers.
108
+ # @api private
109
+ def __agent_execution_owner(execution_id)
110
+ loop_instance = @lifecycle_mutex.synchronize { @event_loop }
111
+ loop_instance&.agent_execution_owner(execution_id)
112
+ end
113
+
114
+ # @api private
115
+ def __team_owner(id, klass:, create:, persistence:, &block)
116
+ @team_ownership_registry.fetch(id, klass: klass, create: create, persistence: persistence, &block)
117
+ end
118
+
119
+ # @api private
120
+ def __get_team(id, klass:)
121
+ @team_ownership_registry.get(id.to_s, klass: klass)
122
+ end
123
+
124
+ # @api private
125
+ def __create_agent(agent_id, expected_class:, &block)
126
+ @agent_ownership_registry.create(agent_id, expected_class: expected_class, &block)
127
+ end
128
+
129
+ # @api private
130
+ def __load_agent(agent_id, expected_class:, &block)
131
+ @agent_ownership_registry.load(agent_id, expected_class: expected_class, &block)
132
+ end
133
+
134
+ # @api private
135
+ def __get_agent(agent_id, expected_class:)
136
+ @agent_ownership_registry.get(agent_id, expected_class: expected_class)
137
+ end
138
+
139
+ # @api private
140
+ def __agent_owned?(agent)
141
+ @agent_ownership_registry.owned?(agent)
142
+ end
143
+
144
+ # @api private
145
+ def __begin_agent_purge(agent)
146
+ @agent_ownership_registry.begin_purge(agent)
147
+ end
148
+
149
+ # @api private
150
+ def __complete_agent_purge(agent, token)
151
+ @agent_ownership_registry.complete_purge(agent, token)
152
+ end
153
+
154
+ # @api private
155
+ def __abort_agent_purge(agent, token)
156
+ @agent_ownership_registry.abort_purge(agent, token)
157
+ end
158
+
159
+ # @api private
160
+ def __leave_agent_purge_uncertain(agent, token)
161
+ @agent_ownership_registry.leave_purge_uncertain(agent, token)
162
+ end
163
+
164
+ # @api private
165
+ def __agent_execution_admitted?(agent_id)
166
+ loop_instance = @lifecycle_mutex.synchronize { @event_loop }
167
+ loop_instance&.agent_execution_admitted?(agent_id) || false
168
+ end
169
+
170
+ # @api private
171
+ def __admit_multi_agent(coordinator)
172
+ current_state = @lifecycle_mutex.synchronize { @state }
173
+ unless current_state == :running
174
+ raise Phronomy::RuntimeShutdownError,
175
+ "Runtime is #{current_state}; new Multi-Agent turns are not accepted"
176
+ end
177
+ @multi_agent_admissions.admit!(coordinator)
178
+ end
179
+
180
+ # @api private
181
+ def __release_multi_agent(coordinator)
182
+ @multi_agent_admissions.release!(coordinator)
115
183
  end
116
184
 
117
185
  def event_loop
@@ -163,23 +231,24 @@ module Phronomy
163
231
  @shutdown_mutex.synchronize do
164
232
  return @shutdown_result if @shutdown_result
165
233
 
166
- # Phase 1 — drain sessions with the full configured grace.
167
234
  drain_deadline = monotonic_now + timeout
168
235
  loop_instance = @lifecycle_mutex.synchronize do
169
236
  @state = :draining unless @state == :failed
170
237
  @event_loop
171
238
  end
172
239
  loop_instance&.begin_draining
240
+ @agent_ownership_registry.begin_draining
241
+ @team_ownership_registry.begin_draining
173
242
 
243
+ admission_idle = @multi_agent_admissions.wait_until_idle(drain_deadline)
244
+ agent_ownership_stable = @agent_ownership_registry.wait_until_stable(drain_deadline)
245
+ team_ownership_stable = @team_ownership_registry.wait_until_stable(drain_deadline)
174
246
  loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
175
247
 
176
248
  @lifecycle_mutex.synchronize do
177
249
  @state = :stopping unless @state == :failed
178
250
  end
179
251
 
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
252
  stop_deadline = monotonic_now + [cancel_grace.to_f, 0.2].max
184
253
  event_loop_status = if loop_instance
185
254
  loop_instance.stop_and_join(deadline: stop_deadline)
@@ -188,7 +257,7 @@ module Phronomy
188
257
  end
189
258
 
190
259
  subsystem_error = shutdown_pools_and_timer
191
- cleanup_complete = loop_idle &&
260
+ cleanup_complete = admission_idle && agent_ownership_stable && team_ownership_stable && loop_idle &&
192
261
  (!loop_instance || !loop_instance.thread_alive?) &&
193
262
  event_loop_status != :cancel_timeout &&
194
263
  subsystem_error.nil?
@@ -200,6 +269,9 @@ module Phronomy
200
269
  :terminated
201
270
  end
202
271
 
272
+ @agent_ownership_registry.shutdown! if cleanup_complete
273
+ @team_ownership_registry.shutdown! if cleanup_complete
274
+
203
275
  result = ShutdownResult.new(
204
276
  runtime_outcome: runtime_outcome,
205
277
  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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Phronomy
6
+ module MultiAgent
7
+ # Agent-owned transactions reserve child work; Task callbacks only wake observers.
8
+ # @api private
9
+ class DurableSubagentCoordinator
10
+ KEY = "multi_agent_coordination_ref"
11
+
12
+ def self.prepare(parent, execution, tx:)
13
+ ref = execution.metadata[KEY]
14
+ snapshot = ref ? tx.contents.fetch_json(ref) : {"kind" => "static_subagent", "children" => []}
15
+ children = snapshot.fetch("children").map(&:dup)
16
+ children.each do |child|
17
+ begin
18
+ exact = tx.executions.load(child.fetch("execution_id"))
19
+ rescue Phronomy::Persistence::NotFoundError
20
+ next
21
+ end
22
+ raise Phronomy::Persistence::ConflictError, "Child owner mismatch" unless exact.agent_id == child.fetch("agent_id")
23
+ child.merge!("state" => exact.status.to_s, "result_ref" => exact.result_ref, "error_ref" => exact.error_ref)
24
+ end
25
+ Array(execution.metadata[Phronomy::Agent::RecoverySupport::TOOL_BATCH_METADATA_KEY]).each do |tool|
26
+ registration = parent.class.registered_subagents.find { |name, _| "dispatch_to_#{name}" == tool.fetch("tool_name") }
27
+ next unless registration
28
+ next if children.any? { |child| child.fetch("slot") == tool.fetch("tool_invocation_id") }
29
+ name, settings = registration
30
+ definition = settings.fetch(:agent_class).agent_definition
31
+ knowledge = settings.fetch(:inherit_knowledge) ? parent.send(:active_knowledge_snapshot) : []
32
+ children << {
33
+ "slot" => tool.fetch("tool_invocation_id"), "name" => name.to_s,
34
+ "definition" => definition.transform_keys(&:to_s),
35
+ "agent_id" => SecureRandom.uuid, "execution_id" => SecureRandom.uuid,
36
+ "input_ref" => tx.contents.put_text(tool.fetch("arguments").fetch("input")),
37
+ "durable_context_ref" => execution.metadata["durable_context_ref"],
38
+ "knowledge_ref" => tx.contents.put_json(Phronomy::Agent::RecoverySupport.canonical_copy(knowledge)),
39
+ "state" => "reserved", "result_ref" => nil, "error_ref" => nil,
40
+ "on_error" => settings.fetch(:on_error).to_s
41
+ }
42
+ end
43
+ return execution if children.empty?
44
+ value = tx.contents.put_json(snapshot.merge("children" => children))
45
+ execution.with(execution_revision: execution.execution_revision, metadata: execution.metadata.merge(KEY => value))
46
+ end
47
+
48
+ def self.start(parent:, tool_invocation_id:, parent_execution_id:, config:)
49
+ runtime = Phronomy::Runtime.instance
50
+ completion = Phronomy::Task.deferred(name: "durable-subagent:#{tool_invocation_id}")
51
+ preparation = runtime.offload.submit(on_full: :raise) do
52
+ current = parent.persistence.executions.load(parent_execution_id)
53
+ raise Phronomy::Persistence::ConflictError, "Parent owner mismatch" unless current.agent_id == parent.agent_id
54
+ snapshot = parent.persistence.contents.fetch_json(current.metadata.fetch(KEY))
55
+ child = snapshot.fetch("children").find { |entry| entry.fetch("slot") == tool_invocation_id }
56
+ raise Phronomy::ExecutionRehydrationRequiredError, "Missing reserved child slot" unless child
57
+ definition = parent.class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
58
+ unless definition && definition.fetch(:agent_class).agent_definition.transform_keys(&:to_s) == child.fetch("definition")
59
+ raise Phronomy::ConfigurationError, "Registered child definition changed: #{child.fetch("name")}"
60
+ end
61
+ klass = definition.fetch(:agent_class)
62
+ id = child.fetch("agent_id")
63
+ agent = klass.get(id)
64
+ unless agent
65
+ begin
66
+ parent.persistence.agents.load(id)
67
+ exists = true
68
+ rescue Phronomy::Persistence::NotFoundError
69
+ exists = false
70
+ end
71
+ listener = parent.send(:_phronomy_event_listener)
72
+ agent = if exists
73
+ klass.load(id, persistence: parent.persistence, on_event: listener)
74
+ else
75
+ knowledge = parent.persistence.contents.fetch_json(child.fetch("knowledge_ref"))
76
+ context = Phronomy::Agent::ContextImporter::ImportedContext.new(records: knowledge.map do |item|
77
+ Phronomy::Agent::ContextImporter::ImportedRecord.new(kind: :knowledge,
78
+ channel: :context, role: :user, content: item.fetch("content"),
79
+ content_format: :text, metadata: item.fetch("metadata"))
80
+ end)
81
+ klass.create(agent_id: id, persistence: parent.persistence,
82
+ context: context, on_event: listener)
83
+ end
84
+ end
85
+ unless agent.persistence.equal?(parent.persistence)
86
+ raise Phronomy::ConfigurationError, "Child Persistence instance mismatch"
87
+ end
88
+ durable_context = child["durable_context_ref"] && parent.persistence.contents.fetch_json(child["durable_context_ref"])
89
+ [child, agent, parent.persistence.contents.fetch_text(child.fetch("input_ref")), durable_context]
90
+ end
91
+ preparation.on_complete do |prepared, failure|
92
+ if failure
93
+ completion.fail(Phronomy::ExecutionRehydrationRequiredError.new("Child reservation requires recovery: #{failure.message}"))
94
+ next
95
+ end
96
+ child, agent, input, durable_context = prepared
97
+ child_config = {cancellation_token: config[:cancellation_token] || Phronomy::Concurrency::CancellationToken.new,
98
+ invocation_context: config[:invocation_context],
99
+ phronomy_coordination: {"kind" => "subagent", "parent_execution_id" => parent_execution_id,
100
+ "parent_agent_id" => parent.agent_id, "slot" => tool_invocation_id}}.compact
101
+ child_config = child_config.merge(durable_context: durable_context) if child["durable_context_ref"]
102
+ source = Phronomy::Agent::ExactExecution.start(agent: agent,
103
+ execution_id: child.fetch("execution_id"), input: input, config: child_config)
104
+ source.on_complete do |result, error|
105
+ if error
106
+ if error.is_a?(Phronomy::CancellationError)
107
+ completion.fail(error)
108
+ else
109
+ completion.fail(Phronomy::ExecutionRehydrationRequiredError.new(
110
+ "Child #{child.fetch("execution_id")} is unfinished: #{error.message}"
111
+ ))
112
+ end
113
+ elsif result[:error]
114
+ if child.fetch("on_error") == "skip"
115
+ completion.complete(nil)
116
+ else
117
+ completion.fail(Phronomy::Agent::RecoverySupport.error_from_failure(result[:error]))
118
+ end
119
+ else
120
+ completion.complete(result[:output])
121
+ end
122
+ end
123
+ rescue => error
124
+ completion.fail(Phronomy::ExecutionRehydrationRequiredError.new("Child coordination requires recovery: #{error.message}"))
125
+ end
126
+ completion
127
+ rescue => error
128
+ completion ||= Phronomy::Task.deferred(name: "durable-subagent")
129
+ completion.fail(error)
130
+ completion
131
+ end
132
+ end
133
+ end
134
+ end