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
@@ -40,17 +40,6 @@ module Phronomy
40
40
  private_constant :APPROVAL_CONFIGURATION_INIT_MUTEX
41
41
 
42
42
  class << self
43
- # Sets or reads the LLM model identifier for this agent.
44
- # When called without an argument, returns the stored model or the
45
- # global default from {Phronomy.configuration}.
46
- #
47
- # @param name [String, nil] model identifier (e.g. "gpt-4o", "claude-3-5-sonnet")
48
- # @return [String, nil] the model name when used as a reader
49
- # @example
50
- # class MyAgent < Phronomy::Agent::Base
51
- # model "gpt-4o"
52
- # end
53
- # @api public
54
43
  def model(name = nil)
55
44
  if name
56
45
  @model = name
@@ -59,22 +48,6 @@ module Phronomy
59
48
  end
60
49
  end
61
50
 
62
- # Sets or reads the system instructions for this agent.
63
- # Accepts a String, a {Phronomy::Agent::Context::Instruction::PromptTemplate}, or a block (Proc).
64
- # When used as a reader (no argument, no block), returns the stored value.
65
- #
66
- # @param text [String, Phronomy::Agent::Context::Instruction::PromptTemplate, nil]
67
- # @yield optionally provide instructions as a block
68
- # @return [String, Phronomy::Agent::Context::Instruction::PromptTemplate, Proc, nil]
69
- # @example String instructions
70
- # class MyAgent < Phronomy::Agent::Base
71
- # instructions "You are a helpful assistant."
72
- # end
73
- # @example Block instructions
74
- # class MyAgent < Phronomy::Agent::Base
75
- # instructions { |input| "Answer in #{input[:lang]}." }
76
- # end
77
- # @api public
78
51
  def instructions(text = nil, &block)
79
52
  if text || block_given?
80
53
  @instructions = text || block
@@ -84,19 +57,6 @@ module Phronomy
84
57
  end
85
58
  end
86
59
 
87
- # Registers tool classes for this agent.
88
- #
89
- # The setter accepts one Hash mapping each Tool class to an explicit alias
90
- # name (String) or nil (use the Tool's own name). Calling without an
91
- # argument returns the registered Tool classes.
92
- #
93
- # @example
94
- # tools(
95
- # Weather::SearchTool => "weather_search",
96
- # Places::SearchTool => "places_search",
97
- # CurrentTimeTool => nil
98
- # )
99
- # @api public
100
60
  def tools(definitions = nil)
101
61
  if definitions.nil?
102
62
  return @tools if instance_variable_defined?(:@tools)
@@ -113,11 +73,6 @@ module Phronomy
113
73
  .reject { |_, value| value.nil? }
114
74
  end
115
75
 
116
- # Returns the alias map registered via .tools.
117
- # Merges parent class aliases so subclasses inherit their parent's mappings.
118
- # Subclass-specific aliases take precedence over parent aliases.
119
- # @return [Hash{Class => String}]
120
- # @api public
121
76
  def tool_aliases
122
77
  own = @tool_aliases || {}
123
78
  if superclass.respond_to?(:tool_aliases)
@@ -127,18 +82,6 @@ module Phronomy
127
82
  end
128
83
  end
129
84
 
130
- # Sets or reads the LLM provider for this agent.
131
- # Required when using a model not registered in RubyLLM's model registry
132
- # (e.g. locally-hosted models via LM Studio or Ollama).
133
- #
134
- # @param name [Symbol, nil] e.g. +:openai+, +:anthropic+, +:ollama+
135
- # @return [Symbol, nil]
136
- # @example
137
- # class MyAgent < Phronomy::Agent::Base
138
- # model "openai/gpt-oss-20b"
139
- # provider :openai
140
- # end
141
- # @api public
142
85
  def provider(name = nil)
143
86
  if name
144
87
  @provider = name
@@ -148,16 +91,6 @@ module Phronomy
148
91
  end
149
92
  end
150
93
 
151
- # Sets or reads the sampling temperature sent to the LLM.
152
- # When nil, the provider's default is used.
153
- #
154
- # @param val [Float, nil] temperature (0.0 to 2.0 depending on provider)
155
- # @return [Float, nil]
156
- # @example
157
- # class MyAgent < Phronomy::Agent::Base
158
- # temperature 0.2
159
- # end
160
- # @api public
161
94
  def temperature(val = nil)
162
95
  if val
163
96
  @temperature = val
@@ -166,16 +99,6 @@ module Phronomy
166
99
  end
167
100
  end
168
101
 
169
- # Sets or reads the maximum number of LLM call cycles for ReAct agents.
170
- # Each tool call and follow-up counts as one iteration. Defaults to 10.
171
- #
172
- # @param val [Integer, nil]
173
- # @return [Integer]
174
- # @example
175
- # class MyAgent < Phronomy::Agent::Base
176
- # max_iterations 5
177
- # end
178
- # @api public
179
102
  def max_iterations(val = nil)
180
103
  if val
181
104
  @max_iterations = val
@@ -184,10 +107,6 @@ module Phronomy
184
107
  end
185
108
  end
186
109
 
187
- # When enabled, attaches Anthropic prompt-cache markers to the system
188
- # message so that the fixed instructions are served from cache on
189
- # subsequent turns, reducing input-token costs.
190
- # @api public
191
110
  def cache_instructions(enabled = nil)
192
111
  if enabled.nil?
193
112
  @cache_instructions
@@ -196,9 +115,6 @@ module Phronomy
196
115
  end
197
116
  end
198
117
 
199
- # Tokens to reserve for the model's output.
200
- # When nil, the model's max_output_tokens from the registry is used.
201
- # @api public
202
118
  def max_output_tokens(val = nil)
203
119
  if val.nil?
204
120
  @max_output_tokens
@@ -207,8 +123,6 @@ module Phronomy
207
123
  end
208
124
  end
209
125
 
210
- # Overrides the context window size used for token budget calculations.
211
- # @api public
212
126
  def context_window(val = nil)
213
127
  if val.nil?
214
128
  @context_window
@@ -217,105 +131,238 @@ module Phronomy
217
131
  end
218
132
  end
219
133
 
220
- # Defines or reads the stable Agent definition identity.
221
- # Subclass with no explicit declaration inherits the parent's definition.
222
- def agent_definition(id: nil, version: nil)
223
- if id || version
224
- raise ArgumentError, "agent_definition requires id: and version:" unless id && version
225
- @agent_definition = {id: id.to_s.freeze, version: Integer(version)}.freeze
134
+ # Binds one Application-constructed ContextPolicy instance to this
135
+ # Agent class. Policy binding is Application code/runtime wiring rather
136
+ # than durable Agent state.
137
+ def context_policy(*args)
138
+ if args.empty?
139
+ return @context_policy if instance_variable_defined?(:@context_policy)
140
+ return superclass.context_policy if superclass.respond_to?(:context_policy)
141
+ return ContextPolicies::Default.instance
226
142
  end
227
- return @agent_definition if @agent_definition
143
+ unless args.length == 1 && args.first.is_a?(ContextPolicy)
144
+ raise ArgumentError,
145
+ "context_policy expects one Phronomy::Agent::ContextPolicy instance"
146
+ end
147
+
148
+ @context_policy = args.first
149
+ end
228
150
 
229
- klass = superclass
230
- while klass.respond_to?(:agent_definition, true) &&
231
- klass < Phronomy::Agent::Base
232
- defn = klass.instance_variable_get(:@agent_definition)
233
- return defn if defn
234
- klass = klass.superclass
151
+ def agent_definition(id: nil, version: nil)
152
+ if !id.nil? || !version.nil?
153
+ raise ArgumentError, "agent_definition requires version:" if version.nil?
154
+
155
+ definition_id = id || name
156
+ unless definition_id
157
+ raise Phronomy::ConfigurationError,
158
+ "anonymous Agent class must declare agent_definition id: ..., version: ..."
159
+ end
160
+
161
+ @agent_definition = {
162
+ id: definition_id.to_s.freeze,
163
+ version: Integer(version)
164
+ }.freeze
235
165
  end
166
+ return @agent_definition if @agent_definition
236
167
 
237
168
  raise Phronomy::ConfigurationError,
238
- "#{name || self} must declare agent_definition id: ..., version: ..."
169
+ "#{name || self} must declare agent_definition version: ..."
239
170
  end
240
171
 
241
- def create(agent_id: SecureRandom.uuid, context: nil, knowledge: [], persistence: nil, metadata: {})
172
+ def create(
173
+ agent_id: SecureRandom.uuid,
174
+ context: nil,
175
+ knowledge: [],
176
+ persistence: nil,
177
+ metadata: {},
178
+ on_event: nil,
179
+ &event_block
180
+ )
242
181
  new(
243
182
  agent_id: agent_id,
244
183
  context: context,
245
184
  knowledge: knowledge,
246
185
  persistence: persistence,
247
- metadata: metadata
186
+ metadata: metadata,
187
+ on_event: on_event,
188
+ &event_block
248
189
  )
249
190
  end
250
191
 
251
- def load(agent_id, persistence:)
252
- new(agent_id: agent_id, persistence: persistence, load_existing: true)
192
+ # Resolves one existing logical Agent. A live process-local owner wins
193
+ # without a Persistence reload; otherwise the durable Agent is hydrated.
194
+ def load(agent_id, persistence:, on_event: nil, &event_block)
195
+ raise ArgumentError, "persistence is required" unless persistence
196
+ if on_event && event_block
197
+ raise ArgumentError, "Provide either on_event: or a block, not both"
198
+ end
199
+
200
+ key = agent_id.to_s
201
+ raise ArgumentError, "agent_id must not be empty" if key.empty?
202
+
203
+ listener_supplied = !on_event.nil? || !event_block.nil?
204
+ runtime = Phronomy::Runtime.instance
205
+ materialized = false
206
+
207
+ agent = runtime.__load_agent(key, expected_class: self) do |owner_runtime|
208
+ materialized = true
209
+ instance = __construct_owned_agent(
210
+ owner_runtime,
211
+ key,
212
+ agent_id: key,
213
+ persistence: persistence,
214
+ load_existing: true,
215
+ on_event: on_event,
216
+ &event_block
217
+ )
218
+ Phronomy::Agent::RecoveryCoordinator.new(instance).recover_on_load!
219
+ instance
220
+ end
221
+
222
+ unless agent.persistence.equal?(persistence)
223
+ raise Phronomy::ConfigurationError,
224
+ "Agent #{key.inspect} is already live with a different Persistence instance"
225
+ end
226
+
227
+ if !materialized && listener_supplied
228
+ raise Phronomy::ConfigurationError,
229
+ "Agent #{key.inspect} is already live; load cannot add, replace, or re-bind on_event"
230
+ end
231
+
232
+ agent
233
+ end
234
+
235
+ # Returns only the process-local live Agent owner. Does not access
236
+ # Persistence and returns nil when this Runtime has no live owner.
237
+ def get(agent_id)
238
+ Phronomy::Runtime.instance.__get_agent(agent_id, expected_class: self)
253
239
  end
254
240
 
255
241
  # Resolves the live Agent instance that currently owns execution_id in
256
- # this process. This is a Runtime-local lookup, not durable rehydration.
242
+ # this process. The Runtime returns only a read-only ownership view;
243
+ # mutable Agent execution state remains EventLoop-owned.
257
244
  def live_for_execution(execution_id)
258
- activation = Phronomy::Runtime.instance.__agent_activations.fetch(execution_id)
259
- unless activation
245
+ owner = Phronomy::Runtime.instance.__agent_execution_owner(execution_id)
246
+ unless owner
260
247
  raise Phronomy::ExecutionRehydrationRequiredError,
261
- "no live activation for #{execution_id}; durable rehydration is required"
248
+ "no live execution owner for #{execution_id}; durable rehydration is required"
262
249
  end
263
250
 
264
- agent = activation.agent
251
+ agent = owner.agent
265
252
  unless agent.is_a?(self)
266
253
  raise ArgumentError,
267
- "live activation #{execution_id} belongs to #{agent.class}, not #{self}"
254
+ "live execution #{execution_id} belongs to #{agent.class}, not #{self}"
268
255
  end
269
256
 
270
257
  agent
271
258
  end
259
+
260
+ private
261
+
262
+ def __construct_owned_agent(owner_runtime, reserved_agent_id, *args, **kwargs, &block)
263
+ instance = allocate
264
+ instance.send(:__prepare_runtime_owner!, owner_runtime, reserved_agent_id)
265
+ instance.send(:initialize, *args, **kwargs, &block)
266
+ instance
267
+ end
268
+ end
269
+
270
+ # @api private
271
+ def __coordination_config
272
+ @_phronomy_coordination_config || {}.freeze
273
+ end
274
+
275
+ # Framework-owned idempotent Tool operations opt into exact replay.
276
+ # @api private
277
+ def __framework_tool_replayable?(_name) = false
278
+
279
+ # @api private
280
+ def __framework_call?(name)
281
+ __framework_tool_replayable?(name) || Array(__coordination_config[:phronomy_handoff_bindings]).any? { |binding| binding.tool_name == name }
272
282
  end
273
283
 
284
+ # Captured Tool wiring is supplied explicitly; this hook only writes semantic values.
285
+ # @api private
286
+ def __prepare_coordination_record(execution, tx:) = execution
287
+
288
+ # Runtime configuration only; no value returned here is serialized.
289
+ # @api private
290
+ def __invocation_config(config) = config
291
+
274
292
  attr_reader :agent_id, :persistence
275
293
 
276
294
  def initialize(
277
- agent_id: SecureRandom.uuid,
295
+ agent_id: nil,
278
296
  context: nil,
279
297
  knowledge: [],
280
298
  persistence: nil,
281
299
  metadata: {},
282
- load_existing: false
300
+ load_existing: false,
301
+ on_event: nil,
302
+ &event_block
283
303
  )
284
- @persistence = persistence ||
285
- Phronomy.configuration.persistence ||
286
- Phronomy::Persistence::InMemory.new
287
- @agent_id = agent_id.to_s.freeze
304
+ if on_event && event_block
305
+ raise ArgumentError, "Provide either on_event: or a block, not both"
306
+ end
307
+ @_phronomy_event_listener = on_event || event_block
288
308
 
289
- if load_existing
290
- root = records = nil
291
- @persistence.transaction do |tx|
292
- root = tx.agents.load(@agent_id)
293
- records = tx.journals.read(
294
- @agent_id,
295
- limit: root.journal_position
296
- )
297
- end
298
- validate_loaded_definition!(root)
299
- @root = root
300
- @_phronomy_journal_records = Array(records).dup.freeze
309
+ reserved_agent_id = @_phronomy_reserved_agent_id
310
+ effective_agent_id = if agent_id.nil?
311
+ reserved_agent_id || SecureRandom.uuid.to_s
301
312
  else
302
- @root = create_agent_root!(
313
+ agent_id.to_s
314
+ end
315
+ raise ArgumentError, "agent_id must not be empty" if effective_agent_id.empty?
316
+
317
+ if @_phronomy_runtime_owner_state == :constructing
318
+ unless reserved_agent_id == effective_agent_id
319
+ raise Phronomy::Error,
320
+ "Agent initializer changed reserved identity from " \
321
+ "#{reserved_agent_id.inspect} to #{effective_agent_id.inspect}"
322
+ end
323
+ initialize_owned_state(
324
+ agent_id: effective_agent_id,
303
325
  context: context,
304
326
  knowledge: knowledge,
305
- metadata: metadata
327
+ persistence: persistence,
328
+ metadata: metadata,
329
+ load_existing: load_existing
306
330
  )
307
- @_phronomy_journal_records = @persistence.journals.read(
308
- @agent_id,
309
- limit: @root.journal_position
310
- ).dup.freeze
331
+ return
332
+ end
333
+
334
+ if load_existing
335
+ raise ArgumentError,
336
+ "load_existing: is an internal hydration option; use .load(agent_id, persistence:)"
337
+ end
338
+
339
+ runtime = Phronomy::Runtime.instance
340
+ begin
341
+ runtime.__create_agent(effective_agent_id, expected_class: self.class) do |owner_runtime|
342
+ __prepare_runtime_owner!(owner_runtime, effective_agent_id)
343
+ initialize_owned_state(
344
+ agent_id: effective_agent_id,
345
+ context: context,
346
+ knowledge: knowledge,
347
+ persistence: persistence,
348
+ metadata: metadata,
349
+ load_existing: false
350
+ )
351
+ self
352
+ end
353
+ rescue Phronomy::Persistence::ConflictError => error
354
+ raise Phronomy::AgentAlreadyExistsError,
355
+ "Agent #{effective_agent_id.inspect} already exists durably: #{error.message}"
311
356
  end
312
357
  end
313
358
 
314
359
  def agent_root
360
+ __assert_agent_accessible!
315
361
  @root
316
362
  end
317
363
 
318
364
  def journal_projection
365
+ __assert_agent_accessible!
319
366
  Agent::JournalProjection.new(
320
367
  agent_root: @root,
321
368
  records: _journal_records_snapshot
@@ -336,8 +383,6 @@ module Phronomy
336
383
  end
337
384
  end
338
385
 
339
- # Logically clears all persistent Knowledge registered before this point.
340
- # Raw Journal records remain append-only and are not deleted.
341
386
  def clear_knowledge!
342
387
  mutate_context!(:knowledge_cleared) do |root|
343
388
  root.with(
@@ -347,9 +392,8 @@ module Phronomy
347
392
  end
348
393
  end
349
394
 
350
- # Appends persistent Knowledge to the Agent Journal. The live Agent owns the
351
- # current logical root/Journal view; Persistence is advanced optimistically.
352
395
  def add_knowledge(content, metadata: {})
396
+ __assert_live_agent!
353
397
  current = agent_root
354
398
  next_root = nil
355
399
  appended = nil
@@ -402,41 +446,194 @@ module Phronomy
402
446
  end
403
447
 
404
448
  def purge!
405
- persistence.transaction do |tx|
406
- tx.executions.assert_idle!(agent_id)
407
- tx.journals.delete(agent_id)
408
- tx.executions.delete_for_agent(agent_id)
409
- tx.agents.delete(agent_id)
449
+ return true if @_phronomy_runtime_owner_state == :purged
450
+
451
+ __assert_live_agent!
452
+ runtime = @_phronomy_runtime_owner
453
+ token = runtime.__begin_agent_purge(self)
454
+ if runtime.__agent_execution_admitted?(agent_id)
455
+ runtime.__abort_agent_purge(self, token)
456
+ raise Phronomy::AgentBusyError,
457
+ "Agent #{agent_id.inspect} has a nonterminal top-level execution"
458
+ end
459
+
460
+ begin
461
+ persistence.transaction do |tx|
462
+ tx.executions.assert_idle!(agent_id)
463
+ if (routing = tx.handoff_states.load(agent_id))
464
+ unless routing.phase == "stable" && tx.executions.list_active(routing.active_agent_id).empty?
465
+ raise Phronomy::AgentBusyError, "Handoff anchor #{agent_id} owns an unfinished turn"
466
+ end
467
+ tx.handoff_states.delete(agent_id, expected_revision: routing.handoff_revision)
468
+ end
469
+ tx.journals.delete(agent_id)
470
+ tx.executions.delete_for_agent(agent_id)
471
+ tx.agents.delete(agent_id)
472
+ end
473
+ rescue Phronomy::AgentBusyError,
474
+ Phronomy::Persistence::ConflictError,
475
+ Phronomy::Persistence::NotFoundError,
476
+ Phronomy::Persistence::SerializationError,
477
+ ArgumentError
478
+ runtime.__abort_agent_purge(self, token)
479
+ raise
480
+ rescue
481
+ # Without F1 reconciliation support we cannot infer that a failed
482
+ # durable delete did not commit. Keep the identity fail-closed.
483
+ runtime.__leave_agent_purge_uncertain(self, token)
484
+ raise
485
+ end
486
+
487
+ runtime.__complete_agent_purge(self, token)
488
+ true
489
+ end
490
+
491
+ # Runtime ownership lifecycle hooks. These are internal coordination
492
+ # methods; application code must use new/create/load/get/purge!.
493
+ private
494
+
495
+ # @api private
496
+ def __prepare_runtime_owner!(runtime, reserved_agent_id)
497
+ @_phronomy_runtime_owner = runtime
498
+ @_phronomy_runtime_owner_state = :constructing
499
+ @_phronomy_reserved_agent_id = reserved_agent_id.to_s.freeze
500
+ self
501
+ end
502
+
503
+ # @api private
504
+ def __bind_runtime_owner!(runtime)
505
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
506
+ @_phronomy_runtime_owner_state == :constructing
507
+ raise Phronomy::Error, "Agent Runtime ownership construction state is invalid"
508
+ end
509
+ @_phronomy_runtime_owner_state = :live
510
+ remove_instance_variable(:@_phronomy_reserved_agent_id) if
511
+ instance_variable_defined?(:@_phronomy_reserved_agent_id)
512
+ self
513
+ end
514
+
515
+ # @api private
516
+ def __mark_purging!(runtime)
517
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
518
+ @_phronomy_runtime_owner_state == :live
519
+ raise Phronomy::Error, "Agent Runtime ownership purge state is invalid"
520
+ end
521
+ @_phronomy_runtime_owner_state = :purging
522
+ self
523
+ end
524
+
525
+ # @api private
526
+ def __restore_live_after_purge_abort!(runtime)
527
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
528
+ @_phronomy_runtime_owner_state == :purging
529
+ raise Phronomy::Error, "Agent Runtime ownership purge rollback state is invalid"
530
+ end
531
+ @_phronomy_runtime_owner_state = :live
532
+ self
533
+ end
534
+
535
+ # @api private
536
+ def __mark_ownership_recovery_required!(runtime)
537
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
538
+ %i[constructing purging].include?(@_phronomy_runtime_owner_state)
539
+ raise Phronomy::Error, "Agent Runtime ownership recovery state is invalid"
540
+ end
541
+ @_phronomy_runtime_owner_state = :recovery_required
542
+ self
543
+ end
544
+
545
+ # @api private
546
+ def __mark_purged!(runtime)
547
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
548
+ @_phronomy_runtime_owner_state == :purging
549
+ raise Phronomy::Error, "Agent Runtime ownership purge completion state is invalid"
410
550
  end
411
551
  @root = nil
412
552
  @_phronomy_journal_records = [].freeze
413
- true
553
+ @_phronomy_runtime_owner_state = :purged
554
+ self
555
+ end
556
+
557
+ # @api private
558
+ def __release_runtime_owner!(runtime)
559
+ return self if @_phronomy_runtime_owner_state == :purged
560
+ return self unless @_phronomy_runtime_owner.equal?(runtime)
561
+
562
+ @_phronomy_runtime_owner_state = :released
563
+ self
414
564
  end
415
565
 
416
- # Internal hook used after a successful Persistence transaction.
566
+ public
567
+
568
+ # Internal EventLoop apply hook used only after a successful durable
569
+ # operation result has been validated by ExecutionCoordinator.
417
570
  def __replace_root(root)
418
571
  @root = root
419
572
  end
420
573
 
421
574
  private
422
575
 
576
+ attr_reader :_phronomy_event_listener
577
+
578
+ def initialize_owned_state(
579
+ agent_id:,
580
+ context:,
581
+ knowledge:,
582
+ persistence:,
583
+ metadata:,
584
+ load_existing:
585
+ )
586
+ @persistence = persistence ||
587
+ Phronomy.configuration.persistence ||
588
+ Phronomy::Persistence::InMemory.new
589
+ @agent_id = agent_id.to_s.freeze
590
+
591
+ if load_existing
592
+ root = records = nil
593
+ @persistence.transaction do |tx|
594
+ root = tx.agents.load(@agent_id)
595
+ records = tx.journals.read(
596
+ @agent_id,
597
+ limit: root.journal_position
598
+ )
599
+ end
600
+ validate_loaded_definition!(root)
601
+ @root = root
602
+ @_phronomy_journal_records = Array(records).dup.freeze
603
+ else
604
+ @root = create_agent_root!(
605
+ context: context,
606
+ knowledge: knowledge,
607
+ metadata: metadata
608
+ )
609
+ @_phronomy_journal_records = @persistence.journals.read(
610
+ @agent_id,
611
+ limit: @root.journal_position
612
+ ).dup.freeze
613
+ end
614
+ end
615
+
423
616
  def validate_loaded_definition!(loaded)
424
617
  definition = self.class.agent_definition
425
- return if loaded.agent_definition_id == definition.fetch(:id) &&
426
- loaded.definition_version == definition.fetch(:version)
618
+ return if current_definition_compatible?(loaded, definition)
427
619
 
428
620
  raise Phronomy::ConfigurationError,
429
621
  "Agent definition mismatch for #{@agent_id}: stored " \
430
- "#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
622
+ "#{loaded.agent_definition_id}@#{loaded.agent_definition_version}, runtime " \
431
623
  "#{definition.fetch(:id)}@#{definition.fetch(:version)}"
432
624
  end
433
625
 
626
+ def current_definition_compatible?(loaded, runtime_definition)
627
+ loaded.agent_definition_id == runtime_definition.fetch(:id) &&
628
+ loaded.agent_definition_version == runtime_definition.fetch(:version)
629
+ end
630
+
434
631
  def create_agent_root!(context:, knowledge:, metadata:)
435
632
  definition = self.class.agent_definition
436
633
  root = Agent::AgentRoot.create(
437
634
  agent_id: agent_id,
438
635
  agent_definition_id: definition.fetch(:id),
439
- definition_version: definition.fetch(:version),
636
+ agent_definition_version: definition.fetch(:version),
440
637
  metadata: metadata
441
638
  )
442
639
  persistence.transaction do |tx|
@@ -507,6 +704,7 @@ module Phronomy
507
704
  end
508
705
 
509
706
  def mutate_context!(kind, context_affecting: true)
707
+ __assert_live_agent!
510
708
  current = agent_root
511
709
  next_root = nil
512
710
  appended = nil
@@ -541,7 +739,8 @@ module Phronomy
541
739
  end
542
740
 
543
741
  def yield_context_revision(current, proposed)
544
- (proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
742
+ (proposed.context_revision == current.context_revision) ?
743
+ current.context_revision + 1 : proposed.context_revision
545
744
  end
546
745
 
547
746
  def _journal_records_snapshot
@@ -558,44 +757,73 @@ module Phronomy
558
757
 
559
758
  public
560
759
 
561
- def _add_handoff_tool(tool_class)
562
- @_handoff_tools ||= []
563
- @_handoff_tools << tool_class
564
- self
565
- end
566
-
567
- def _handoff_tools
568
- @_handoff_tools || []
569
- end
570
-
571
760
  def tool_approval_policy(&block)
761
+ __assert_live_agent!
572
762
  raise ArgumentError, "tool_approval_policy requires a block" unless block
573
763
 
574
764
  _approval_configuration_mutex.synchronize { @tool_approval_policy = block }
575
765
  self
576
766
  end
577
767
 
578
- def on_tool_approval_required(&block)
579
- raise ArgumentError, "on_tool_approval_required requires a block" unless block
768
+ private
580
769
 
581
- _approval_configuration_mutex.synchronize { @tool_approval_listener = block }
582
- self
770
+ def __assert_agent_accessible!
771
+ case @_phronomy_runtime_owner_state
772
+ when :constructing, :live
773
+ true
774
+ when :purged
775
+ raise Phronomy::AgentPurgedError, "Agent #{agent_id.inspect} has been purged"
776
+ when :purging
777
+ raise Phronomy::Error, "Agent #{agent_id.inspect} is being purged"
778
+ when :recovery_required
779
+ raise Phronomy::Error,
780
+ "Agent #{agent_id.inspect} ownership requires durable recovery/reconciliation"
781
+ else
782
+ raise Phronomy::RuntimeShutdownError,
783
+ "Agent #{agent_id.inspect} is detached from its Runtime"
784
+ end
583
785
  end
584
786
 
585
- private
787
+ def __assert_live_agent!
788
+ __assert_agent_accessible!
789
+ return true if @_phronomy_runtime_owner_state == :constructing
790
+
791
+ runtime = @_phronomy_runtime_owner
792
+ unless runtime&.__agent_owned?(self)
793
+ raise Phronomy::RuntimeShutdownError,
794
+ "Agent #{agent_id.inspect} is no longer the live owner in its Runtime"
795
+ end
796
+ true
797
+ end
798
+
799
+ def _prepare_invocation_config(config, invocation_context)
800
+ __assert_live_agent!
801
+ _reject_removed_generic_identity_keys!(config)
802
+ effective_config = invocation_context ?
803
+ config.merge(invocation_context: invocation_context) : config
586
804
 
587
- def _apply_invocation_context(thread_id, config, ic)
588
- effective_thread_id = thread_id || ic.thread_id
589
- effective_config = config.merge(invocation_context: ic)
590
- if effective_config[:cancellation_token].nil?
591
- if (tok = ic.effective_timeout_token)
805
+ if invocation_context && effective_config[:cancellation_token].nil?
806
+ if (tok = invocation_context.effective_timeout_token)
592
807
  effective_config = effective_config.merge(
593
808
  cancellation_token: tok,
594
- phronomy_timeout_deadline: ic.deadline
809
+ phronomy_timeout_deadline: invocation_context.deadline
595
810
  )
596
811
  end
597
812
  end
598
- [effective_thread_id, effective_config]
813
+ effective_config
814
+ end
815
+
816
+ def _reject_removed_generic_identity_keys!(config)
817
+ removed_key = [
818
+ :thread_id, "thread_id",
819
+ :session_id, "session_id",
820
+ :agent_invocation_id, "agent_invocation_id"
821
+ ].find { |key| config.key?(key) }
822
+ return unless removed_key
823
+
824
+ raise ArgumentError,
825
+ "Agent identity #{removed_key.inspect} was removed; " \
826
+ "use purpose-specific domain identifiers or application tracing metadata"
599
827
  end
600
828
 
601
829
  def _check_event_loop_reentrancy(sync_method, async_method)
@@ -644,12 +872,12 @@ module Phronomy
644
872
  end
645
873
  end
646
874
 
647
- def _report_stream_callback_error(callback_error, event:, invocation_id:,
875
+ def _report_stream_callback_error(callback_error, event:, execution_id:,
648
876
  callback_error_policy:)
649
877
  lines = [
650
878
  "[Phronomy] Stream callback failed",
651
879
  "event=#{event.type.inspect}",
652
- "agent_invocation_id=#{invocation_id || "unknown"}",
880
+ "execution_id=#{execution_id || "unknown"}",
653
881
  "policy=#{callback_error_policy.inspect}",
654
882
  "error=#{callback_error.class}: #{callback_error.message}"
655
883
  ]
@@ -704,7 +932,6 @@ module Phronomy
704
932
  def _build_caller_meta(config)
705
933
  meta = {}
706
934
  meta[:user_id] = config[:user_id] if config[:user_id]
707
- meta[:session_id] = config[:session_id] if config[:session_id]
708
935
  if (ic = config[:invocation_context])
709
936
  meta[:task_id] = ic.task_id if ic.task_id
710
937
  meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
@@ -825,17 +1052,23 @@ module Phronomy
825
1052
  }
826
1053
  end
827
1054
 
828
- # Base#call_async ultimately executes #call, so the synchronous wrapper
829
- # above already applies filters for ordinary Tools. Agent-backed Tools
830
- # override #call_async and bypass #call; only those custom async Tools
831
- # need an asynchronous result-filter wrapper here.
832
1055
  if custom_async_call
833
1056
  define_method(:call_async) do |args, **kwargs|
834
1057
  source = super(args, **kwargs)
835
- filtered = Phronomy::Task.deferred(name: "tool-filter-#{name}")
1058
+ filtered = Phronomy::Concurrency::PhysicalCompletionTask.deferred(
1059
+ name: "tool-filter-#{name}"
1060
+ )
1061
+ source_has_physical_signal = source.respond_to?(:on_physical_complete)
1062
+ source.on_physical_complete { filtered.mark_physical_complete! } if
1063
+ source_has_physical_signal
836
1064
  source.on_complete do |value, error|
837
1065
  if error
838
- filtered.fail(error)
1066
+ filtered.mark_physical_complete! unless source_has_physical_signal
1067
+ if source.respond_to?(:status) && source.status == :cancelled
1068
+ filtered.cancel!(error)
1069
+ else
1070
+ filtered.fail(error)
1071
+ end
839
1072
  next
840
1073
  end
841
1074
 
@@ -843,8 +1076,10 @@ module Phronomy
843
1076
  result = result_filters.inject(value) { |val, filter|
844
1077
  filter.call(val, tool_name: name, args: args)
845
1078
  }
1079
+ filtered.mark_physical_complete! unless source_has_physical_signal
846
1080
  filtered.complete(result)
847
1081
  rescue => filter_error
1082
+ filtered.mark_physical_complete! unless source_has_physical_signal
848
1083
  filtered.fail(filter_error)
849
1084
  end
850
1085
  end