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
@@ -1,210 +1,558 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require "time"
5
+ require "digest"
4
6
 
5
7
  module Phronomy
6
8
  module MultiAgent
7
- # Coordinator/worker multi-agent pattern with persistent worker Agent state.
9
+ # Durable purpose-specific task allocation. Agent owns every Provider/Tool
10
+ # execution; TeamExecution owns queue, assignments and aggregate facts.
11
+ # Scheduling and aggregation must be pure, replay-safe result calculations.
12
+ # @api public
8
13
  class TeamCoordinator
9
- WorkerState = Struct.new(
10
- :index,
11
- :agent,
12
- :transcript_size,
13
- :status
14
- ) do
15
- def available? = %i[idle available].include?(status)
14
+ WorkerState = Data.define(:index, :agent_id, :transcript_size, :status) do
15
+ def available? = status == :available
16
16
  end
17
17
  private_constant :WorkerState
18
18
 
19
19
  class << self
20
20
  # @api public
21
- def coordinator_model(value = nil)
22
- value ? @coordinator_model = value : @coordinator_model
21
+ def team_definition(id: nil, version: nil)
22
+ if id || version
23
+ definition_id = id || name
24
+ unless definition_id && !definition_id.to_s.empty? && version && Integer(version).positive?
25
+ raise ArgumentError, "team_definition requires id and positive version"
26
+ end
27
+ @team_definition = {id: (id || name).to_s.freeze, version: Integer(version)}.freeze
28
+ end
29
+ @team_definition || raise(Phronomy::ConfigurationError, "Team must declare team_definition id: ..., version: ...")
23
30
  end
24
31
 
25
32
  # @api public
26
- def coordinator_instructions(value = nil)
27
- value ? @coordinator_instructions = value : @coordinator_instructions
28
- end
29
-
33
+ def coordinator_model(value = nil) = value ? @coordinator_model = value : @coordinator_model
30
34
  # @api public
31
- def coordinator_provider(value = nil)
32
- value ? @coordinator_provider = value : @coordinator_provider
33
- end
35
+ def coordinator_instructions(value = nil) = value ? @coordinator_instructions = value : @coordinator_instructions
36
+ # @api public
37
+ def coordinator_provider(value = nil) = value ? @coordinator_provider = value : @coordinator_provider
34
38
 
35
39
  # @api public
36
40
  def pool(size:, agent:, on_error: :raise)
37
- @pool_size = Integer(size)
38
- @worker_agent = agent
39
- @on_error = on_error
41
+ raise ArgumentError, "pool size must be positive" unless Integer(size).positive?
42
+ raise ArgumentError, "on_error must be :raise or :skip" unless %i[raise skip].include?(on_error)
43
+ raise ArgumentError, "pool agent must be an Agent class" unless agent.is_a?(Class) && agent <= Phronomy::Agent::Base
44
+ @pool_size, @worker_agent, @on_error = Integer(size), agent, on_error
40
45
  end
41
46
 
42
47
  # @api public
43
- def schedule(&block)
44
- @scheduler = block
48
+ def schedule(&block) = @scheduler = block
49
+ # @api public
50
+ def aggregate(&block) = @aggregator = block
51
+
52
+ # @api public
53
+ def new(team_id: SecureRandom.uuid, persistence: nil, metadata: {}, on_event: nil)
54
+ construct(team_id, persistence, metadata, on_event, create: true)
45
55
  end
46
56
 
47
57
  # @api public
48
- def aggregate(&block)
49
- @aggregator = block
58
+ def create(**options) = new(**options)
59
+
60
+ # Hydrates Team facts only; use resume with the exact run ID to continue.
61
+ # @api public
62
+ def load(team_id, persistence:, on_event: nil)
63
+ owner = get(team_id)
64
+ if owner && on_event
65
+ raise Phronomy::ConfigurationError, "Team #{team_id} is already live; listener cannot be rebound"
66
+ end
67
+ construct(team_id, persistence, {}, on_event, create: false)
50
68
  end
51
69
 
70
+ # @api public
71
+ def get(team_id) = Phronomy::Runtime.instance.__get_team(team_id, klass: self)
72
+
73
+ # @api private
52
74
  def _coordinator_model = @coordinator_model
75
+ # @api private
53
76
  def _coordinator_instructions = @coordinator_instructions
77
+ # @api private
54
78
  def _coordinator_provider = @coordinator_provider
79
+ # @api private
55
80
  def _pool_size = @pool_size || 1
81
+ # @api private
56
82
  def _worker_agent = @worker_agent
83
+ # @api private
57
84
  def _on_error = @on_error || :raise
85
+ # @api private
58
86
  def _scheduler = @scheduler
87
+ # @api private
59
88
  def _aggregator = @aggregator
89
+
90
+ private
91
+
92
+ def construct(id, persistence, metadata, listener, create:)
93
+ raise Phronomy::EventLoopReentrancyError, "Team construction cannot block EventLoop" if Phronomy::Runtime.in_event_loop_context?
94
+ key = id.to_s
95
+ raise ArgumentError, "team_id must not be empty" if key.empty?
96
+ store = persistence || Phronomy.configuration.persistence || Phronomy::Persistence::InMemory.new
97
+ runtime = Phronomy::Runtime.instance
98
+ runtime.__team_owner(key, klass: self, create: create, persistence: store) do
99
+ instance = allocate
100
+ instance.send(:initialize, key.freeze, store, metadata, listener, runtime, create: create)
101
+ instance
102
+ end
103
+ end
60
104
  end
61
105
 
106
+ # @api public
107
+ attr_reader :team_id, :persistence
108
+
62
109
  # @api public
63
110
  def invoke(team_input, config: {})
64
- unless self.class._worker_agent
65
- raise ArgumentError, "pool :agent must be configured before invoking"
111
+ with_admission do
112
+ execution = admit(team_input.to_s, config)
113
+ run(execution.team_execution_id, config)
66
114
  end
67
-
68
- task_queue = []
69
- run_coordinator(team_input, task_queue)
70
- assignments = run_workers(task_queue)
71
- finalize_result(assignments)
72
115
  end
73
116
 
117
+ # Callback events are observations of committed assignments, never an outbox.
74
118
  # @api public
75
119
  def stream(team_input, config: {}, &block)
76
- return invoke(team_input, config: config) unless block
77
-
78
- unless self.class._worker_agent
79
- raise ArgumentError, "pool :agent must be configured before invoking"
120
+ with_admission do
121
+ execution = admit(team_input.to_s, config)
122
+ run(execution.team_execution_id, config, &block)
80
123
  end
124
+ end
81
125
 
82
- task_queue = []
83
- run_coordinator(team_input, task_queue)
84
- assignments = run_workers(task_queue, &block)
85
- finalize_result(assignments)
126
+ # @api public
127
+ def resume(team_execution_id, config: {})
128
+ with_admission { run(team_execution_id.to_s, config) }
86
129
  end
87
130
 
88
- private
131
+ # No live Agent loading or callbacks are needed for result discovery.
132
+ # @api public
133
+ def result(team_execution_id)
134
+ execution = read_execution(team_execution_id)
135
+ persistence.team_execution_result(execution.team_execution_id)
136
+ end
89
137
 
90
- def run_coordinator(team_input, task_queue)
91
- coordinator = build_coordinator_agent(task_queue)
92
- input = team_input.is_a?(String) ? team_input : team_input.to_s
93
- coordinator.invoke(input)
138
+ # @api public
139
+ def executions(after: nil, limit: 100)
140
+ persistence.list_team_executions(team_id, after: after, limit: limit)
94
141
  end
95
142
 
96
- def run_workers(task_queue, &event_block)
97
- pool_size = self.class._pool_size
98
- agent_class = self.class._worker_agent
99
- on_error = self.class._on_error
100
- scheduler = self.class._scheduler
143
+ # Requests cancellation of this run only. Existing Agent cancellation and
144
+ # Recovery settle admitted children; reserved absent children are not started.
145
+ # @api public
146
+ def cancel(team_execution_id)
147
+ assert_caller!
148
+ id = team_execution_id.to_s
149
+ update(id) do |current, _tx|
150
+ next current unless current.active?
151
+ current.with(metadata: current.metadata.merge("cancel_requested" => true))
152
+ end
153
+ token = @tokens_mutex.synchronize { @tokens[id] }
154
+ token&.cancel!
155
+ result(id)
156
+ end
101
157
 
102
- workers = Array.new(pool_size) do |index|
103
- WorkerState.new(
104
- index: index,
105
- agent: agent_class.new,
106
- transcript_size: 0,
107
- status: :idle
108
- )
158
+ private
159
+
160
+ def initialize(id, store, metadata, listener, runtime, create:)
161
+ @team_id, @persistence, @listener, @runtime = id, store, listener, runtime
162
+ @tokens_mutex, @tokens = Mutex.new, {}
163
+ @coordinator_classes = {}
164
+ definition = self.class.team_definition
165
+ if create
166
+ now = Time.now.utc.iso8601(6)
167
+ intended = TeamRoot.new(team_id: id, team_definition_id: definition.fetch(:id),
168
+ team_definition_version: definition.fetch(:version), team_revision: 0,
169
+ lifecycle_status: "idle", created_at: now, updated_at: now, metadata: metadata)
170
+ begin
171
+ store.transaction { |tx| tx.teams.create(intended) }
172
+ rescue => error
173
+ confirmed = store.teams.load(id)
174
+ raise error unless confirmed.to_h == intended.to_h
175
+ end
176
+ end
177
+ root = store.teams.load(id)
178
+ unless root.team_definition_id == definition.fetch(:id) && root.team_definition_version == definition.fetch(:version)
179
+ raise Phronomy::ConfigurationError, "Team #{id} definition mismatch"
180
+ end
181
+ end
182
+
183
+ def assert_caller!
184
+ raise Phronomy::EventLoopReentrancyError, "Team operation cannot block EventLoop" if Phronomy::Runtime.in_event_loop_context?
185
+ unless @runtime.equal?(Phronomy::Runtime.instance)
186
+ raise Phronomy::RuntimeShutdownError, "Team #{team_id} belongs to a previous Runtime"
109
187
  end
188
+ end
110
189
 
111
- assignments = []
190
+ def with_admission
191
+ assert_caller!
192
+ @runtime.__admit_multi_agent(self)
193
+ admitted = true
194
+ yield
195
+ ensure
196
+ @runtime.__release_multi_agent(self) if admitted
197
+ end
198
+
199
+ def read_execution(id)
200
+ execution = persistence.team_executions.load(id)
201
+ unless execution.team_id == team_id
202
+ raise Phronomy::Persistence::ConflictError, "Team execution #{id} belongs to another Team"
203
+ end
204
+ execution
205
+ end
112
206
 
113
- until task_queue.empty?
114
- task = task_queue.shift
115
- available = workers.select(&:available?)
116
- worker = scheduler ? scheduler.call(available) : default_scheduler(available)
207
+ def definition_snapshot
208
+ worker = self.class._worker_agent
209
+ raise Phronomy::ConfigurationError, "Team pool agent is missing" unless worker
210
+ {"worker" => worker.agent_definition.transform_keys(&:to_s), "pool_size" => self.class._pool_size,
211
+ "on_error" => self.class._on_error.to_s,
212
+ "coordinator" => self.class.team_definition.transform_keys(&:to_s)}
213
+ end
117
214
 
215
+ def admit(input, config = {})
216
+ definition = definition_snapshot
217
+ id = SecureRandom.uuid
218
+ now = Time.now.utc.iso8601(6)
219
+ intended = nil
220
+ begin
221
+ persistence.transaction do |tx|
222
+ root = tx.teams.load(team_id)
223
+ raise Phronomy::AgentBusyError, "Team #{team_id} has an active run; use resume" unless tx.team_executions.list_active(team_id).empty?
224
+ intended = TeamExecution.new(team_execution_id: id, team_id: team_id,
225
+ execution_revision: 0, status: "active", phase: "coordinator", input_ref: tx.contents.put_text(input),
226
+ coordinator: {"agent_id" => SecureRandom.uuid, "execution_id" => SecureRandom.uuid, "state" => "reserved"},
227
+ workers: Array.new(self.class._pool_size) { |i| {"index" => i, "agent_id" => SecureRandom.uuid, "transcript_size" => 0} },
228
+ tasks: [], assignments: [], result_ref: nil, error_ref: nil, created_at: now, updated_at: now,
229
+ metadata: {"definition" => definition, "operations" => {}, "cancel_requested" => false,
230
+ "durable_context_ref" => config.key?(:durable_context) ? tx.contents.put_json(config.fetch(:durable_context)) : nil})
231
+ tx.team_executions.create_active(intended)
232
+ tx.teams.save(team_id, expected_revision: root.team_revision, root: root.with(lifecycle_status: "active"))
233
+ end
234
+ rescue => error
235
+ raise error unless intended
118
236
  begin
119
- result = worker.agent.invoke(task[:description])
120
- worker.transcript_size = worker.agent.transcript.length
121
- worker.status = :available
122
- entry = {
123
- task: task,
124
- result: result[:output],
125
- worker: worker.index,
126
- error: nil
127
- }
128
- assignments << entry
129
- event_block&.call(entry.merge(type: :task_completed))
130
- rescue => error
131
- worker.status = :available
132
- raise unless on_error == :skip
237
+ confirmed = read_execution(id)
238
+ rescue Phronomy::Persistence::NotFoundError
239
+ raise error
240
+ end
241
+ raise error unless confirmed.to_h == intended.to_h
242
+ end
243
+ intended
244
+ end
133
245
 
134
- entry = {
135
- task: task,
136
- result: nil,
137
- worker: worker.index,
138
- error: error
139
- }
140
- assignments << entry
141
- event_block&.call(entry.merge(type: :task_failed))
246
+ # The body only derives immutable facts. Failed readback propagates; it is
247
+ # never interpreted as absence. A known CAS loser may rebase the same fact.
248
+ def update(id)
249
+ attempts = 0
250
+ begin
251
+ intended = nil
252
+ persistence.transaction do |tx|
253
+ current = tx.team_executions.load(id)
254
+ raise Phronomy::Persistence::ConflictError, "Team execution owner mismatch" unless current.team_id == team_id
255
+ intended = yield(current, tx)
256
+ next if intended.equal?(current)
257
+ tx.team_executions.save(id, expected_revision: current.execution_revision, execution: intended)
258
+ if intended.terminal? && current.active?
259
+ root = tx.teams.load(team_id)
260
+ tx.teams.save(team_id, expected_revision: root.team_revision, root: root.with(lifecycle_status: "idle"))
261
+ end
262
+ end
263
+ intended
264
+ rescue => error
265
+ confirmed = read_execution(id)
266
+ return confirmed if intended && confirmed.to_h == intended.to_h
267
+ if error.is_a?(Phronomy::Persistence::ConflictError) && (attempts += 1) < 8
268
+ retry
142
269
  end
270
+ raise error
143
271
  end
272
+ end
144
273
 
145
- workers.each { |worker| worker.status = :done }
146
- assignments
274
+ def run(id, config)
275
+ current = read_execution(id)
276
+ return terminal_value(current) if current.terminal?
277
+ unless current.metadata.fetch("definition") == definition_snapshot
278
+ raise Phronomy::ConfigurationError, "Team execution #{id} pool/definition mismatch"
279
+ end
280
+ token = Phronomy::Concurrency::CancellationToken.new
281
+ @tokens_mutex.synchronize { @tokens[id] = token }
282
+ external = config[:cancellation_token]
283
+ callback = proc { @runtime.offload.submit(on_full: :raise) { cancel(id) } }
284
+ external&.on_cancel(&callback)
285
+ token.cancel! if current.metadata["cancel_requested"]
286
+ begin
287
+ loop do
288
+ current = read_execution(id)
289
+ return terminal_value(current) if current.terminal?
290
+ token.cancel! if cancellation_requested?(current)
291
+ action, subject = next_run_action(current)
292
+ case action
293
+ when :coordinator
294
+ outcome = run_child(current, current.coordinator, coordinator_class(id),
295
+ persistence.contents.fetch_text(current.input_ref), "coordinator", config, token)
296
+ update(id) do |fresh, _tx|
297
+ fresh.with(phase: "workers", coordinator: fresh.coordinator.merge(
298
+ "state" => outcome[:status].to_s, "result_ref" => outcome[:result_ref], "error_ref" => outcome[:error_ref]
299
+ ))
300
+ end
301
+ next
302
+ when :assignment
303
+ unfinished = subject
304
+ worker = current.workers.fetch(unfinished.fetch("worker"))
305
+ task = current.tasks.find { |item| item.fetch("id") == unfinished.fetch("task_id") }
306
+ outcome = run_child(current, worker.merge("execution_id" => unfinished.fetch("execution_id")),
307
+ self.class._worker_agent, task.fetch("description"), unfinished.fetch("task_id"), config, token)
308
+ saved = record_assignment(id, unfinished, outcome)
309
+ entry = assignment_values(saved).find { |item| item.fetch(:task).fetch(:id) == task.fetch("id") }
310
+ yield entry.merge(type: outcome[:error] ? :task_failed : :task_completed) if block_given?
311
+ next
312
+ when :failed
313
+ return terminal_value(finish_error(id, persistence.contents.fetch_json(subject.fetch("error_ref"))))
314
+ when :cancelled
315
+ return terminal_value(finish_error(id, {"class" => "Phronomy::CancellationError", "message" => "Team run cancelled"}, status: "cancelled"))
316
+ when :reserve
317
+ reserve_assignment(current, subject)
318
+ next
319
+ end
320
+ values = assignment_values(current)
321
+ aggregate = self.class._aggregator
322
+ begin
323
+ output = Phronomy::Agent::RecoverySupport.canonical_copy(aggregate ? aggregate.call(values) : values)
324
+ Phronomy::CanonicalJSON.dump(output)
325
+ rescue => error
326
+ return terminal_value(finish_error(id, Phronomy::Agent::RecoverySupport.resolution_failure(error)))
327
+ end
328
+ # Aggregate may be replayed only until the canonical outcome commits.
329
+ completed = update(id) do |fresh, tx|
330
+ next fresh if fresh.terminal? || fresh.metadata["cancel_requested"]
331
+ fresh.with(status: "completed", phase: "completed", result_ref: tx.contents.put_json(output))
332
+ end
333
+ next if completed.active?
334
+ return terminal_value(completed)
335
+ end
336
+ rescue Phronomy::CancellationError
337
+ # An absent reserved child is not started just to cancel it. Keep its
338
+ # identity in the terminal Team record for discovery.
339
+ if (failed = failed_child(read_execution(id)))
340
+ terminal_value(finish_error(id, persistence.contents.fetch_json(failed.fetch("error_ref"))))
341
+ else
342
+ terminal_value(finish_error(id, {"class" => "Phronomy::CancellationError", "message" => "Team run cancelled"}, status: "cancelled"))
343
+ end
344
+ ensure
345
+ external&.send(:unregister_cancel_callback, callback)
346
+ @tokens_mutex.synchronize { @tokens.delete(id) }
347
+ end
147
348
  end
148
349
 
149
- def finalize_result(assignments)
150
- aggregator = self.class._aggregator
151
- aggregator ? aggregator.call(assignments) : assignments
350
+ # Interpret only durable child states. Normal execution returns here after
351
+ # each child commit, just as resume does after losing its Runtime observer.
352
+ # Reconcile reservations before claiming completion; a cancelled absent
353
+ # child is handled by run_child without dispatching it.
354
+ def next_run_action(execution)
355
+ return [:coordinator, nil] if execution.phase == "coordinator"
356
+ unfinished = execution.assignments.find { |entry| entry.fetch("state") == "reserved" }
357
+ return [:assignment, unfinished] if unfinished
358
+ failed = failed_child(execution)
359
+ return [:failed, failed] if failed
360
+ return [:cancelled, nil] if cancellation_requested?(execution)
361
+
362
+ task = execution.tasks.find { |item| execution.assignments.none? { |entry| entry.fetch("task_id") == item.fetch("id") } }
363
+ task ? [:reserve, task] : [:aggregate, nil]
152
364
  end
153
365
 
154
- def default_scheduler(available_workers)
155
- available_workers.min_by(&:transcript_size)
366
+ def failed_child(execution)
367
+ children = [execution.coordinator]
368
+ children += execution.assignments if execution.metadata.dig("definition", "on_error") == "raise"
369
+ # Filters deliberately block an Agent with a distinct terminal status.
370
+ # Team treats this as a child failure while retaining the blocked fact.
371
+ children.find { |entry| %w[failed blocked].include?(entry.fetch("state")) }
156
372
  end
157
373
 
158
- def build_coordinator_agent(task_queue)
159
- coordinator_model_val = self.class._coordinator_model
160
- coordinator_instructions_val = self.class._coordinator_instructions
161
- coordinator_provider_val = self.class._coordinator_provider
162
- enqueue_tool = build_enqueue_tool(task_queue)
163
- finalize_tool = build_finalize_tool(task_queue)
374
+ def cancellation_requested?(execution)
375
+ execution.metadata["cancel_requested"] || execution.coordinator.fetch("state") == "cancelled" ||
376
+ execution.assignments.any? { |entry| entry.fetch("state") == "cancelled" }
377
+ end
164
378
 
165
- coordinator_class = Class.new(Phronomy::Agent::Base) do
166
- agent_definition id: "team-coordinator-#{SecureRandom.hex(4)}", version: 1
167
- model coordinator_model_val
168
- provider coordinator_provider_val if coordinator_provider_val
169
- instructions coordinator_instructions_val
170
- tools(enqueue_tool => nil, finalize_tool => nil)
379
+ def run_child(current, slot, klass, input, purpose, config, token)
380
+ id = slot.fetch("agent_id")
381
+ child = klass.get(id)
382
+ unless child
383
+ begin
384
+ persistence.agents.load(id)
385
+ present = true
386
+ rescue Phronomy::Persistence::NotFoundError
387
+ present = false
388
+ end
389
+ token.raise_if_cancelled! unless present
390
+ child = present ? klass.load(id, persistence: persistence, on_event: @listener) : klass.create(agent_id: id, persistence: persistence, on_event: @listener)
171
391
  end
392
+ unless child.persistence.equal?(persistence)
393
+ raise Phronomy::ConfigurationError, "Team child #{id} Persistence mismatch"
394
+ end
395
+ child_config = config.merge(cancellation_token: token,
396
+ phronomy_coordination: {"kind" => "team", "team_id" => team_id,
397
+ "team_execution_id" => current.team_execution_id, "slot" => purpose})
398
+ if (context_ref = current.metadata["durable_context_ref"])
399
+ child_config = child_config.merge(durable_context: persistence.contents.fetch_json(context_ref))
400
+ end
401
+ Phronomy::Agent::ExactExecution.start(agent: child, execution_id: slot.fetch("execution_id"), input: input, config: child_config).wait_result
402
+ end
172
403
 
173
- coordinator_class.new
404
+ def reserve_assignment(current, task)
405
+ available = current.workers.map { |w| WorkerState.new(index: w.fetch("index"), agent_id: w.fetch("agent_id"), transcript_size: w.fetch("transcript_size"), status: :available) }.freeze
406
+ worker = self.class._scheduler ? self.class._scheduler.call(available) : available.min_by(&:transcript_size)
407
+ raise Phronomy::ConfigurationError, "Scheduler must select an available worker slot" unless available.include?(worker)
408
+ reserved = {"task_id" => task.fetch("id"), "worker" => worker.index,
409
+ "execution_id" => SecureRandom.uuid, "state" => "reserved", "result_ref" => nil, "error_ref" => nil}
410
+ update(current.team_execution_id) do |fresh, _tx|
411
+ next fresh if fresh.metadata["cancel_requested"] || fresh.assignments.any? { |entry| entry.fetch("task_id") == task.fetch("id") }
412
+ fresh.with(assignments: fresh.assignments + [reserved])
413
+ end
174
414
  end
175
415
 
176
- def build_enqueue_tool(task_queue)
177
- Class.new(Phronomy::Agent::Context::Capability::Base) do
178
- tool_name "enqueue_task"
179
- description "Add a task to the worker queue."
180
- execution_mode :cooperative
181
- param :description, type: :string, desc: "What the worker agent should do"
182
- param :metadata, type: :string, desc: "Optional metadata", required: false
416
+ def record_assignment(id, assigned, outcome)
417
+ update(id) do |fresh, tx|
418
+ entries = fresh.assignments.map do |entry|
419
+ next entry unless entry.fetch("task_id") == assigned.fetch("task_id")
420
+ next entry unless entry.fetch("state") == "reserved"
421
+ raise Phronomy::Persistence::ConflictError, "Assignment execution changed" unless entry.fetch("execution_id") == outcome.fetch(:execution_id)
422
+ entry.merge("state" => outcome.fetch(:status).to_s,
423
+ "result_ref" => outcome[:result_ref], "error_ref" => outcome[:error_ref])
424
+ end
425
+ workers = fresh.workers.map do |worker|
426
+ next worker unless worker.fetch("index") == assigned.fetch("worker")
427
+ root = tx.agents.load(worker.fetch("agent_id"))
428
+ worker.merge("transcript_size" => root.journal_position)
429
+ end
430
+ fresh.with(assignments: entries, workers: workers)
431
+ end
432
+ end
183
433
 
184
- define_method(:execute) do |description:, metadata: nil|
185
- task = {
186
- id: task_queue.size + 1,
187
- description: description,
188
- metadata: metadata,
189
- enqueued_at: Time.now
190
- }
191
- task_queue << task
192
- "Task ##{task[:id]} enqueued: #{description}"
434
+ def assignment_values(execution)
435
+ execution.assignments.map do |entry|
436
+ task = execution.tasks.find { |item| item.fetch("id") == entry.fetch("task_id") }
437
+ {task: task.transform_keys(&:to_sym), worker: entry.fetch("worker"),
438
+ result: entry["result_ref"] && persistence.contents.fetch_text(entry.fetch("result_ref")),
439
+ error: entry["error_ref"] && persistence.contents.fetch_json(entry.fetch("error_ref"))}.freeze
440
+ end.freeze
441
+ end
442
+
443
+ def finish_error(id, error, status: "failed")
444
+ update(id) do |fresh, tx|
445
+ next fresh if fresh.terminal?
446
+ fresh.with(status: status, phase: status, error_ref: tx.contents.put_json(error))
447
+ end
448
+ end
449
+
450
+ def terminal_value(execution)
451
+ raise Phronomy::CancellationError, "Team run #{execution.team_execution_id} cancelled" if execution.status == "cancelled"
452
+ raise Phronomy::Agent::RecoverySupport.error_from_failure(persistence.contents.fetch_json(execution.error_ref)) if execution.error_ref
453
+ persistence.contents.fetch_json(execution.result_ref)
454
+ end
455
+
456
+ def coordinator_class(id)
457
+ @coordinator_classes[id] ||= begin
458
+ definition = self.class.team_definition
459
+ model = self.class._coordinator_model
460
+ provider = self.class._coordinator_provider
461
+ instructions = self.class._coordinator_instructions
462
+ enqueue = build_operation_tool(id, :enqueue_task)
463
+ finalize = build_operation_tool(id, :finalize)
464
+ Class.new(Phronomy::Agent::Base) do
465
+ agent_definition id: "team:#{definition.fetch(:id)}:coordinator", version: definition.fetch(:version)
466
+ model(model) if model
467
+ provider(provider) if provider
468
+ instructions(instructions) if instructions
469
+ tools(enqueue => nil, finalize => nil)
470
+ define_method(:__framework_tool_replayable?) { |name| %w[enqueue_task finalize].include?(name) }
193
471
  end
194
472
  end
195
473
  end
196
474
 
197
- def build_finalize_tool(task_queue)
475
+ def build_operation_tool(run_id, operation)
476
+ team = self
198
477
  Class.new(Phronomy::Agent::Context::Capability::Base) do
199
- tool_name "finalize"
200
- description "Signal that task generation is complete. Call this after all tasks have been enqueued."
478
+ def self.__framework_owned_operation? = true
479
+ tool_name operation.to_s
480
+ description((operation == :enqueue_task) ? "Add a task to the worker queue." : "Finish task generation.")
201
481
  execution_mode :cooperative
202
- param :summary, type: :string, desc: "Brief summary of what was enqueued", required: false
482
+ if operation == :enqueue_task
483
+ param :description, type: :string, desc: "Worker task"
484
+ param :metadata, type: :string, desc: "Optional metadata", required: false
485
+ else
486
+ param :summary, type: :string, desc: "Task summary", required: false
487
+ end
488
+ define_method(:call_async) do |args, cancellation_token: nil, config: {}|
489
+ validated, schema_error = send(:validate_and_coerce, args)
490
+ raise Phronomy::ToolError, schema_error if schema_error
491
+ execute_async(**validated, cancellation_token: cancellation_token, config: config)
492
+ rescue => error
493
+ Phronomy::Task.deferred(name: "team-operation-failed").tap { |task| task.fail(error) }
494
+ end
495
+ define_method(:execute_async) do |config: {}, cancellation_token: nil, **arguments|
496
+ key = config.fetch(:phronomy_tool_invocation_id)
497
+ Phronomy::Runtime.instance.offload.submit(on_full: :raise) do
498
+ team.send(:apply_operation, run_id, key, operation, arguments)
499
+ rescue Phronomy::CancellationError
500
+ raise
501
+ rescue => error
502
+ raise Phronomy::ExecutionRehydrationRequiredError, "Team operation #{key} needs reconciliation: #{error.message}"
503
+ end
504
+ end
505
+ private :execute_async
506
+ end
507
+ end
203
508
 
204
- define_method(:execute) do |summary: ""|
205
- "Finalized. #{task_queue.size} task(s) enqueued. #{summary}".strip
509
+ def apply_operation(run_id, key, operation, arguments)
510
+ argument_values = Phronomy::Agent::RecoverySupport.canonical_copy(arguments)
511
+ current = update(run_id) do |fresh, tx|
512
+ operations = fresh.metadata.fetch("operations")
513
+ if (prior = operations[key])
514
+ unless prior.fetch("operation") == operation.to_s && prior.fetch("arguments") == argument_values
515
+ raise Phronomy::Persistence::ConflictError, "Team operation #{key} identity mismatch"
516
+ end
517
+ next fresh
518
+ end
519
+ raise Phronomy::CancellationError, "Team run cancelled" if fresh.metadata["cancel_requested"]
520
+ raise Phronomy::Persistence::ConflictError, "Team task generation is closed" unless fresh.active? && fresh.phase == "coordinator"
521
+ coordinator = tx.executions.load(fresh.coordinator.fetch("execution_id"))
522
+ unless coordinator.agent_id == fresh.coordinator.fetch("agent_id")
523
+ raise Phronomy::Persistence::ConflictError, "Team coordinator owner mismatch"
524
+ end
525
+ batch = Array(coordinator.metadata[Phronomy::Agent::RecoverySupport::TOOL_BATCH_METADATA_KEY])
526
+ requested = batch.find { |entry| entry.fetch("tool_invocation_id") == key }
527
+ unless requested && requested.fetch("status") == "authorized" && requested.fetch("tool_name") == operation.to_s && requested.fetch("arguments").compact == argument_values
528
+ raise Phronomy::Persistence::ConflictError, "Team operation #{key} is not the authorized call"
529
+ end
530
+ # All calls passed the Agent authorization barrier. Commit this finite
531
+ # batch in Provider order, so finalize cannot overtake queued tasks.
532
+ tasks = fresh.tasks.dup
533
+ operations = operations.dup
534
+ metadata = fresh.metadata.dup
535
+ batch.each do |entry|
536
+ entry_id = entry.fetch("tool_invocation_id")
537
+ next if operations.key?(entry_id) || entry.fetch("status") != "authorized"
538
+ name = entry.fetch("tool_name")
539
+ next unless %w[enqueue_task finalize].include?(name)
540
+ values = entry.fetch("arguments").compact
541
+ if name == "enqueue_task"
542
+ raise Phronomy::ConfigurationError, "Cannot enqueue after finalize" if metadata["finalized"]
543
+ task = {"id" => Digest::SHA256.hexdigest(entry_id)[0, 32], "description" => values.fetch("description"), "metadata" => values["metadata"]}
544
+ tasks << task
545
+ output = "Task ##{tasks.length} enqueued: #{task.fetch("description")}"
546
+ else
547
+ output = "Finalized. #{tasks.size} task(s) enqueued. #{values["summary"]}".strip
548
+ metadata["finalized"] = true
549
+ end
550
+ operations[entry_id] = {"operation" => name, "arguments" => values, "result" => output}
206
551
  end
552
+ metadata["operations"] = operations
553
+ fresh.with(tasks: tasks, metadata: metadata)
207
554
  end
555
+ current.metadata.fetch("operations").fetch(key).fetch("result")
208
556
  end
209
557
  end
210
558
  end