phronomy 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +152 -0
  15. data/docs/architecture/persistence.md +175 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +82 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -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,216 @@ 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
150
+
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
228
160
 
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
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
272
268
  end
273
269
 
274
270
  attr_reader :agent_id, :persistence
275
271
 
276
272
  def initialize(
277
- agent_id: SecureRandom.uuid,
273
+ agent_id: nil,
278
274
  context: nil,
279
275
  knowledge: [],
280
276
  persistence: nil,
281
277
  metadata: {},
282
- load_existing: false
278
+ load_existing: false,
279
+ on_event: nil,
280
+ &event_block
283
281
  )
284
- @persistence = persistence ||
285
- Phronomy.configuration.persistence ||
286
- Phronomy::Persistence::InMemory.new
287
- @agent_id = agent_id.to_s.freeze
282
+ if on_event && event_block
283
+ raise ArgumentError, "Provide either on_event: or a block, not both"
284
+ end
285
+ @_phronomy_event_listener = on_event || event_block
288
286
 
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
287
+ reserved_agent_id = @_phronomy_reserved_agent_id
288
+ effective_agent_id = if agent_id.nil?
289
+ reserved_agent_id || SecureRandom.uuid.to_s
301
290
  else
302
- @root = create_agent_root!(
291
+ agent_id.to_s
292
+ end
293
+ raise ArgumentError, "agent_id must not be empty" if effective_agent_id.empty?
294
+
295
+ if @_phronomy_runtime_owner_state == :constructing
296
+ unless reserved_agent_id == effective_agent_id
297
+ raise Phronomy::Error,
298
+ "Agent initializer changed reserved identity from " \
299
+ "#{reserved_agent_id.inspect} to #{effective_agent_id.inspect}"
300
+ end
301
+ initialize_owned_state(
302
+ agent_id: effective_agent_id,
303
303
  context: context,
304
304
  knowledge: knowledge,
305
- metadata: metadata
305
+ persistence: persistence,
306
+ metadata: metadata,
307
+ load_existing: load_existing
306
308
  )
307
- @_phronomy_journal_records = @persistence.journals.read(
308
- @agent_id,
309
- limit: @root.journal_position
310
- ).dup.freeze
309
+ return
310
+ end
311
+
312
+ if load_existing
313
+ raise ArgumentError,
314
+ "load_existing: is an internal hydration option; use .load(agent_id, persistence:)"
315
+ end
316
+
317
+ runtime = Phronomy::Runtime.instance
318
+ begin
319
+ runtime.__create_agent(effective_agent_id, expected_class: self.class) do |owner_runtime|
320
+ __prepare_runtime_owner!(owner_runtime, effective_agent_id)
321
+ initialize_owned_state(
322
+ agent_id: effective_agent_id,
323
+ context: context,
324
+ knowledge: knowledge,
325
+ persistence: persistence,
326
+ metadata: metadata,
327
+ load_existing: false
328
+ )
329
+ self
330
+ end
331
+ rescue Phronomy::Persistence::ConflictError => error
332
+ raise Phronomy::AgentAlreadyExistsError,
333
+ "Agent #{effective_agent_id.inspect} already exists durably: #{error.message}"
311
334
  end
312
335
  end
313
336
 
314
337
  def agent_root
338
+ __assert_agent_accessible!
315
339
  @root
316
340
  end
317
341
 
318
342
  def journal_projection
343
+ __assert_agent_accessible!
319
344
  Agent::JournalProjection.new(
320
345
  agent_root: @root,
321
346
  records: _journal_records_snapshot
@@ -336,8 +361,6 @@ module Phronomy
336
361
  end
337
362
  end
338
363
 
339
- # Logically clears all persistent Knowledge registered before this point.
340
- # Raw Journal records remain append-only and are not deleted.
341
364
  def clear_knowledge!
342
365
  mutate_context!(:knowledge_cleared) do |root|
343
366
  root.with(
@@ -347,9 +370,8 @@ module Phronomy
347
370
  end
348
371
  end
349
372
 
350
- # Appends persistent Knowledge to the Agent Journal. The live Agent owns the
351
- # current logical root/Journal view; Persistence is advanced optimistically.
352
373
  def add_knowledge(content, metadata: {})
374
+ __assert_live_agent!
353
375
  current = agent_root
354
376
  next_root = nil
355
377
  appended = nil
@@ -402,41 +424,188 @@ module Phronomy
402
424
  end
403
425
 
404
426
  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)
427
+ return true if @_phronomy_runtime_owner_state == :purged
428
+
429
+ __assert_live_agent!
430
+ runtime = @_phronomy_runtime_owner
431
+ token = runtime.__begin_agent_purge(self)
432
+ if runtime.__agent_execution_admitted?(agent_id)
433
+ runtime.__abort_agent_purge(self, token)
434
+ raise Phronomy::AgentBusyError,
435
+ "Agent #{agent_id.inspect} has a nonterminal top-level execution"
436
+ end
437
+
438
+ begin
439
+ persistence.transaction do |tx|
440
+ tx.executions.assert_idle!(agent_id)
441
+ tx.journals.delete(agent_id)
442
+ tx.executions.delete_for_agent(agent_id)
443
+ tx.agents.delete(agent_id)
444
+ end
445
+ rescue Phronomy::AgentBusyError,
446
+ Phronomy::Persistence::ConflictError,
447
+ Phronomy::Persistence::NotFoundError,
448
+ Phronomy::Persistence::SerializationError,
449
+ ArgumentError
450
+ runtime.__abort_agent_purge(self, token)
451
+ raise
452
+ rescue
453
+ # Without F1 reconciliation support we cannot infer that a failed
454
+ # durable delete did not commit. Keep the identity fail-closed.
455
+ runtime.__leave_agent_purge_uncertain(self, token)
456
+ raise
457
+ end
458
+
459
+ runtime.__complete_agent_purge(self, token)
460
+ true
461
+ end
462
+
463
+ # Runtime ownership lifecycle hooks. These are internal coordination
464
+ # methods; application code must use new/create/load/get/purge!.
465
+ private
466
+
467
+ # @api private
468
+ def __prepare_runtime_owner!(runtime, reserved_agent_id)
469
+ @_phronomy_runtime_owner = runtime
470
+ @_phronomy_runtime_owner_state = :constructing
471
+ @_phronomy_reserved_agent_id = reserved_agent_id.to_s.freeze
472
+ self
473
+ end
474
+
475
+ # @api private
476
+ def __bind_runtime_owner!(runtime)
477
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
478
+ @_phronomy_runtime_owner_state == :constructing
479
+ raise Phronomy::Error, "Agent Runtime ownership construction state is invalid"
480
+ end
481
+ @_phronomy_runtime_owner_state = :live
482
+ remove_instance_variable(:@_phronomy_reserved_agent_id) if
483
+ instance_variable_defined?(:@_phronomy_reserved_agent_id)
484
+ self
485
+ end
486
+
487
+ # @api private
488
+ def __mark_purging!(runtime)
489
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
490
+ @_phronomy_runtime_owner_state == :live
491
+ raise Phronomy::Error, "Agent Runtime ownership purge state is invalid"
492
+ end
493
+ @_phronomy_runtime_owner_state = :purging
494
+ self
495
+ end
496
+
497
+ # @api private
498
+ def __restore_live_after_purge_abort!(runtime)
499
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
500
+ @_phronomy_runtime_owner_state == :purging
501
+ raise Phronomy::Error, "Agent Runtime ownership purge rollback state is invalid"
502
+ end
503
+ @_phronomy_runtime_owner_state = :live
504
+ self
505
+ end
506
+
507
+ # @api private
508
+ def __mark_ownership_recovery_required!(runtime)
509
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
510
+ %i[constructing purging].include?(@_phronomy_runtime_owner_state)
511
+ raise Phronomy::Error, "Agent Runtime ownership recovery state is invalid"
512
+ end
513
+ @_phronomy_runtime_owner_state = :recovery_required
514
+ self
515
+ end
516
+
517
+ # @api private
518
+ def __mark_purged!(runtime)
519
+ unless @_phronomy_runtime_owner.equal?(runtime) &&
520
+ @_phronomy_runtime_owner_state == :purging
521
+ raise Phronomy::Error, "Agent Runtime ownership purge completion state is invalid"
410
522
  end
411
523
  @root = nil
412
524
  @_phronomy_journal_records = [].freeze
413
- true
525
+ @_phronomy_runtime_owner_state = :purged
526
+ self
414
527
  end
415
528
 
416
- # Internal hook used after a successful Persistence transaction.
529
+ # @api private
530
+ def __release_runtime_owner!(runtime)
531
+ return self if @_phronomy_runtime_owner_state == :purged
532
+ return self unless @_phronomy_runtime_owner.equal?(runtime)
533
+
534
+ @_phronomy_runtime_owner_state = :released
535
+ self
536
+ end
537
+
538
+ public
539
+
540
+ # Internal EventLoop apply hook used only after a successful durable
541
+ # operation result has been validated by ExecutionCoordinator.
417
542
  def __replace_root(root)
418
543
  @root = root
419
544
  end
420
545
 
421
546
  private
422
547
 
548
+ attr_reader :_phronomy_event_listener
549
+
550
+ def initialize_owned_state(
551
+ agent_id:,
552
+ context:,
553
+ knowledge:,
554
+ persistence:,
555
+ metadata:,
556
+ load_existing:
557
+ )
558
+ @persistence = persistence ||
559
+ Phronomy.configuration.persistence ||
560
+ Phronomy::Persistence::InMemory.new
561
+ @agent_id = agent_id.to_s.freeze
562
+
563
+ if load_existing
564
+ root = records = nil
565
+ @persistence.transaction do |tx|
566
+ root = tx.agents.load(@agent_id)
567
+ records = tx.journals.read(
568
+ @agent_id,
569
+ limit: root.journal_position
570
+ )
571
+ end
572
+ validate_loaded_definition!(root)
573
+ @root = root
574
+ @_phronomy_journal_records = Array(records).dup.freeze
575
+ else
576
+ @root = create_agent_root!(
577
+ context: context,
578
+ knowledge: knowledge,
579
+ metadata: metadata
580
+ )
581
+ @_phronomy_journal_records = @persistence.journals.read(
582
+ @agent_id,
583
+ limit: @root.journal_position
584
+ ).dup.freeze
585
+ end
586
+ end
587
+
423
588
  def validate_loaded_definition!(loaded)
424
589
  definition = self.class.agent_definition
425
- return if loaded.agent_definition_id == definition.fetch(:id) &&
426
- loaded.definition_version == definition.fetch(:version)
590
+ return if current_definition_compatible?(loaded, definition)
427
591
 
428
592
  raise Phronomy::ConfigurationError,
429
593
  "Agent definition mismatch for #{@agent_id}: stored " \
430
- "#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
594
+ "#{loaded.agent_definition_id}@#{loaded.agent_definition_version}, runtime " \
431
595
  "#{definition.fetch(:id)}@#{definition.fetch(:version)}"
432
596
  end
433
597
 
598
+ def current_definition_compatible?(loaded, runtime_definition)
599
+ loaded.agent_definition_id == runtime_definition.fetch(:id) &&
600
+ loaded.agent_definition_version == runtime_definition.fetch(:version)
601
+ end
602
+
434
603
  def create_agent_root!(context:, knowledge:, metadata:)
435
604
  definition = self.class.agent_definition
436
605
  root = Agent::AgentRoot.create(
437
606
  agent_id: agent_id,
438
607
  agent_definition_id: definition.fetch(:id),
439
- definition_version: definition.fetch(:version),
608
+ agent_definition_version: definition.fetch(:version),
440
609
  metadata: metadata
441
610
  )
442
611
  persistence.transaction do |tx|
@@ -507,6 +676,7 @@ module Phronomy
507
676
  end
508
677
 
509
678
  def mutate_context!(kind, context_affecting: true)
679
+ __assert_live_agent!
510
680
  current = agent_root
511
681
  next_root = nil
512
682
  appended = nil
@@ -541,7 +711,8 @@ module Phronomy
541
711
  end
542
712
 
543
713
  def yield_context_revision(current, proposed)
544
- (proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
714
+ (proposed.context_revision == current.context_revision) ?
715
+ current.context_revision + 1 : proposed.context_revision
545
716
  end
546
717
 
547
718
  def _journal_records_snapshot
@@ -558,44 +729,73 @@ module Phronomy
558
729
 
559
730
  public
560
731
 
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
732
  def tool_approval_policy(&block)
733
+ __assert_live_agent!
572
734
  raise ArgumentError, "tool_approval_policy requires a block" unless block
573
735
 
574
736
  _approval_configuration_mutex.synchronize { @tool_approval_policy = block }
575
737
  self
576
738
  end
577
739
 
578
- def on_tool_approval_required(&block)
579
- raise ArgumentError, "on_tool_approval_required requires a block" unless block
740
+ private
580
741
 
581
- _approval_configuration_mutex.synchronize { @tool_approval_listener = block }
582
- self
742
+ def __assert_agent_accessible!
743
+ case @_phronomy_runtime_owner_state
744
+ when :constructing, :live
745
+ true
746
+ when :purged
747
+ raise Phronomy::AgentPurgedError, "Agent #{agent_id.inspect} has been purged"
748
+ when :purging
749
+ raise Phronomy::Error, "Agent #{agent_id.inspect} is being purged"
750
+ when :recovery_required
751
+ raise Phronomy::Error,
752
+ "Agent #{agent_id.inspect} ownership requires durable recovery/reconciliation"
753
+ else
754
+ raise Phronomy::RuntimeShutdownError,
755
+ "Agent #{agent_id.inspect} is detached from its Runtime"
756
+ end
583
757
  end
584
758
 
585
- private
759
+ def __assert_live_agent!
760
+ __assert_agent_accessible!
761
+ return true if @_phronomy_runtime_owner_state == :constructing
586
762
 
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)
763
+ runtime = @_phronomy_runtime_owner
764
+ unless runtime&.__agent_owned?(self)
765
+ raise Phronomy::RuntimeShutdownError,
766
+ "Agent #{agent_id.inspect} is no longer the live owner in its Runtime"
767
+ end
768
+ true
769
+ end
770
+
771
+ def _prepare_invocation_config(config, invocation_context)
772
+ __assert_live_agent!
773
+ _reject_removed_generic_identity_keys!(config)
774
+ effective_config = invocation_context ?
775
+ config.merge(invocation_context: invocation_context) : config
776
+
777
+ if invocation_context && effective_config[:cancellation_token].nil?
778
+ if (tok = invocation_context.effective_timeout_token)
592
779
  effective_config = effective_config.merge(
593
780
  cancellation_token: tok,
594
- phronomy_timeout_deadline: ic.deadline
781
+ phronomy_timeout_deadline: invocation_context.deadline
595
782
  )
596
783
  end
597
784
  end
598
- [effective_thread_id, effective_config]
785
+ effective_config
786
+ end
787
+
788
+ def _reject_removed_generic_identity_keys!(config)
789
+ removed_key = [
790
+ :thread_id, "thread_id",
791
+ :session_id, "session_id",
792
+ :agent_invocation_id, "agent_invocation_id"
793
+ ].find { |key| config.key?(key) }
794
+ return unless removed_key
795
+
796
+ raise ArgumentError,
797
+ "Agent identity #{removed_key.inspect} was removed; " \
798
+ "use purpose-specific domain identifiers or application tracing metadata"
599
799
  end
600
800
 
601
801
  def _check_event_loop_reentrancy(sync_method, async_method)
@@ -644,12 +844,12 @@ module Phronomy
644
844
  end
645
845
  end
646
846
 
647
- def _report_stream_callback_error(callback_error, event:, invocation_id:,
847
+ def _report_stream_callback_error(callback_error, event:, execution_id:,
648
848
  callback_error_policy:)
649
849
  lines = [
650
850
  "[Phronomy] Stream callback failed",
651
851
  "event=#{event.type.inspect}",
652
- "agent_invocation_id=#{invocation_id || "unknown"}",
852
+ "execution_id=#{execution_id || "unknown"}",
653
853
  "policy=#{callback_error_policy.inspect}",
654
854
  "error=#{callback_error.class}: #{callback_error.message}"
655
855
  ]
@@ -704,7 +904,6 @@ module Phronomy
704
904
  def _build_caller_meta(config)
705
905
  meta = {}
706
906
  meta[:user_id] = config[:user_id] if config[:user_id]
707
- meta[:session_id] = config[:session_id] if config[:session_id]
708
907
  if (ic = config[:invocation_context])
709
908
  meta[:task_id] = ic.task_id if ic.task_id
710
909
  meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
@@ -825,17 +1024,23 @@ module Phronomy
825
1024
  }
826
1025
  end
827
1026
 
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
1027
  if custom_async_call
833
1028
  define_method(:call_async) do |args, **kwargs|
834
1029
  source = super(args, **kwargs)
835
- filtered = Phronomy::Task.deferred(name: "tool-filter-#{name}")
1030
+ filtered = Phronomy::Concurrency::PhysicalCompletionTask.deferred(
1031
+ name: "tool-filter-#{name}"
1032
+ )
1033
+ source_has_physical_signal = source.respond_to?(:on_physical_complete)
1034
+ source.on_physical_complete { filtered.mark_physical_complete! } if
1035
+ source_has_physical_signal
836
1036
  source.on_complete do |value, error|
837
1037
  if error
838
- filtered.fail(error)
1038
+ filtered.mark_physical_complete! unless source_has_physical_signal
1039
+ if source.respond_to?(:status) && source.status == :cancelled
1040
+ filtered.cancel!(error)
1041
+ else
1042
+ filtered.fail(error)
1043
+ end
839
1044
  next
840
1045
  end
841
1046
 
@@ -843,8 +1048,10 @@ module Phronomy
843
1048
  result = result_filters.inject(value) { |val, filter|
844
1049
  filter.call(val, tool_name: name, args: args)
845
1050
  }
1051
+ filtered.mark_physical_complete! unless source_has_physical_signal
846
1052
  filtered.complete(result)
847
1053
  rescue => filter_error
1054
+ filtered.mark_physical_complete! unless source_has_physical_signal
848
1055
  filtered.fail(filter_error)
849
1056
  end
850
1057
  end