phronomy 0.30.1 → 0.32.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +13 -2
  4. data/docs/architecture/agent-tool-child-lifetime.md +141 -0
  5. data/docs/architecture/dx05-agent-scope.md +4 -2
  6. data/docs/architecture/structured-output-and-scope.md +73 -13
  7. data/docs/decisions/044-agent-default-and-one-shot-composition.md +5 -0
  8. data/docs/features.md +2 -0
  9. data/docs/getting-started.md +26 -0
  10. data/docs/migrations/0.31.md +72 -0
  11. data/docs/runtime-and-concurrency.md +159 -0
  12. data/lib/phronomy/agent/approval_wait.rb +8 -0
  13. data/lib/phronomy/agent/async_event_api.rb +77 -8
  14. data/lib/phronomy/agent/base.rb +28 -3
  15. data/lib/phronomy/agent/composition/run_once.rb +11 -10
  16. data/lib/phronomy/agent/context_preparation.rb +1 -0
  17. data/lib/phronomy/agent/execution/dispatch_preparation.rb +1 -1
  18. data/lib/phronomy/agent/execution/exact_execution.rb +9 -3
  19. data/lib/phronomy/agent/execution/execution_coordinator.rb +95 -2
  20. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -6
  21. data/lib/phronomy/agent/execution/initial_preparation.rb +1 -1
  22. data/lib/phronomy/agent/execution/invocation_actions.rb +17 -15
  23. data/lib/phronomy/agent/execution_change.rb +4 -0
  24. data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
  25. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
  26. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
  27. data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
  28. data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
  29. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
  30. data/lib/phronomy/agent/output_contract.rb +47 -5
  31. data/lib/phronomy/agent/persistence/codec.rb +16 -2
  32. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +1 -0
  33. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
  34. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -2
  35. data/lib/phronomy/agent/runtime_input.rb +7 -0
  36. data/lib/phronomy/agent/store.rb +52 -0
  37. data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
  38. data/lib/phronomy/engine/fsm_session.rb +16 -3
  39. data/lib/phronomy/execution/async_operation.rb +16 -0
  40. data/lib/phronomy/execution/concurrency/result_finalization.rb +106 -0
  41. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +3 -3
  42. data/lib/phronomy/generation/generator_verifier.rb +6 -3
  43. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +1 -1
  44. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +3 -1
  45. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
  46. data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
  47. data/lib/phronomy/tools/agent.rb +58 -22
  48. data/lib/phronomy/version.rb +1 -1
  49. data/lib/phronomy/workflow/execution/workflow.rb +9 -0
  50. data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
  51. data/scripts/api_snapshot.rb +2 -0
  52. data/sig/phronomy/agent/execution_contract.rbs +13 -0
  53. data/sig/phronomy/agent/store.rbs +1 -0
  54. data/sig/phronomy/agent.rbs +8 -5
  55. data/sig/phronomy/execution/async_operation.rbs +1 -0
  56. data/sig/phronomy/generation/generator_verifier.rbs +1 -1
  57. data/sig/phronomy/multi_agent.rbs +2 -0
  58. data/sig/phronomy/tools/agent.rbs +14 -0
  59. data/sig/phronomy/workflow.rbs +1 -0
  60. metadata +8 -2
@@ -6,46 +6,48 @@ module Phronomy
6
6
  # Session construction and resource selection are supplied by the environment.
7
7
  # @api private
8
8
  class InvocationActions
9
- def self.build_entry_actions(agent, environment, mode:, event_sink:)
9
+ def self.build_entry_actions(environment, mode:, event_sink:)
10
+ # Generated machine callbacks can outlive a session through VM method
11
+ # caches. Resolve the Agent from the current invocation, never capture it.
10
12
  calling_action = if mode.to_sym == :stream
11
- method(:calling_llm_stream_action).curry.call(agent, environment, event_sink)
13
+ method(:calling_llm_stream_action).curry.call(environment, event_sink)
12
14
  else
13
- method(:calling_llm_action).curry.call(agent, environment, event_sink)
15
+ method(:calling_llm_action).curry.call(environment, event_sink)
14
16
  end
15
17
 
16
18
  {
17
- filtering_input: [method(:apply_prepared_input_action).curry.call(agent)],
18
- building_context: [method(:prepare_runtime_input_action).curry.call(agent)],
19
+ filtering_input: [method(:apply_prepared_input_action)],
20
+ building_context: [method(:prepare_runtime_input_action)],
19
21
  calling_llm: [calling_action],
20
22
  starting_tools: [method(:starting_tools_action).curry.call(environment, event_sink)],
21
23
  dispatching_tools: [method(:dispatching_tools_action).curry.call(environment, event_sink)],
22
24
  recording_tool_results: [method(:recording_tool_results_action)],
23
25
  suspended: [method(:suspended_action)],
24
- output_filtering: [method(:output_filtering_action).curry.call(agent)],
26
+ output_filtering: [method(:output_filtering_action)],
25
27
  failed: [method(:failed_action)]
26
28
  }
27
29
  end
28
30
 
29
- def self.apply_prepared_input_action(_agent, invocation)
31
+ def self.apply_prepared_input_action(invocation)
30
32
  invocation.input = invocation.config.fetch(:phronomy_filtered_input)
31
33
  invocation
32
34
  end
33
35
  private_class_method :apply_prepared_input_action
34
36
 
35
- def self.prepare_runtime_input_action(agent, invocation)
37
+ def self.prepare_runtime_input_action(invocation)
36
38
  projection = invocation.config.fetch(:phronomy_runtime_projection)
37
- agent.send(:prepare_runtime_input, projection, invocation: invocation)
39
+ invocation.agent.send(:prepare_runtime_input, projection, invocation: invocation)
38
40
  end
39
41
  private_class_method :prepare_runtime_input_action
40
42
 
41
- def self.calling_llm_action(agent, environment, event_sink, invocation)
42
- prepare_and_start_llm_call(agent, environment, event_sink, invocation, streaming: false)
43
+ def self.calling_llm_action(environment, event_sink, invocation)
44
+ prepare_and_start_llm_call(invocation.agent, environment, event_sink, invocation, streaming: false)
43
45
  invocation
44
46
  end
45
47
  private_class_method :calling_llm_action
46
48
 
47
- def self.calling_llm_stream_action(agent, environment, event_sink, invocation)
48
- prepare_and_start_llm_call(agent, environment, event_sink, invocation, streaming: true)
49
+ def self.calling_llm_stream_action(environment, event_sink, invocation)
50
+ prepare_and_start_llm_call(invocation.agent, environment, event_sink, invocation, streaming: true)
49
51
  invocation
50
52
  end
51
53
  private_class_method :calling_llm_stream_action
@@ -354,8 +356,8 @@ module Phronomy
354
356
  end
355
357
  private_class_method :failed_action
356
358
 
357
- def self.output_filtering_action(agent, invocation)
358
- invocation.output = agent.send(:run_output_filters!, invocation.output)
359
+ def self.output_filtering_action(invocation)
360
+ invocation.output = invocation.agent.send(:run_output_filters!, invocation.output)
359
361
  invocation
360
362
  rescue Phronomy::FilterBlockError => error
361
363
  invocation.output_blocked = true
@@ -75,6 +75,10 @@ module Phronomy
75
75
  return @result if outcome.disposition == :committed
76
76
  raise error if outcome.disposition == :not_committed
77
77
  raise Phronomy::ExecutionRehydrationRequiredError, "Execution change outcome is unknown: #{(outcome.read_error || error).message}"
78
+ ensure
79
+ # A completed synchronous change may remain on a worker's native stack.
80
+ # Release live execution wiring only after commit/readback has finished.
81
+ @agent = @write = @participant = nil
78
82
  end
79
83
 
80
84
  private :perform_once
@@ -37,7 +37,12 @@ module Phronomy
37
37
  # clear_transcript! does not remove Knowledge, while knowledge_cleared and
38
38
  # context_reset invalidate earlier Knowledge records by Journal position.
39
39
  def context_records
40
- (transcript_records + active_knowledge_records)
40
+ rejected_batches = rejected_tool_batches
41
+ messages = transcript_records.reject do |record|
42
+ %i[assistant_message tool_message].include?(record.kind) &&
43
+ record.llm_call_id && rejected_batches[record.execution_id] == record.llm_call_id
44
+ end
45
+ (messages + active_knowledge_records)
41
46
  .sort_by { |record| [record.sequence || 0, record.record_id] }
42
47
  .freeze
43
48
  end
@@ -52,6 +57,24 @@ module Phronomy
52
57
 
53
58
  private
54
59
 
60
+ # Rejection ends the execution at its final provider Tool batch. That
61
+ # exchange has no complete set of Tool replies to send to a provider.
62
+ # Identify it from existing journal facts, including pre-upgrade histories.
63
+ def rejected_tool_batches
64
+ rejected = {}
65
+ batches = {}
66
+ records.reverse_each do |record|
67
+ if record.kind == :execution_rejected
68
+ rejected[record.execution_id] = true
69
+ elsif record.kind == :assistant_message && rejected.delete(record.execution_id)
70
+ if Array(record.metadata["tool_call_ids"]).any? && record.llm_call_id
71
+ batches[record.execution_id] = record.llm_call_id
72
+ end
73
+ end
74
+ end
75
+ batches
76
+ end
77
+
55
78
  def active_knowledge_records
56
79
  reset_sequence = records.filter_map do |record|
57
80
  record.sequence if KNOWLEDGE_RESET_KINDS.include?(record.kind)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # An opt-in host handle. wait_result yields the ordinary terminal result or
6
+ # an ApprovalWait when the hosted operation itself is detached.
7
+ class HostedInvocation
8
+ def initialize(agent, execution_id, task, resumed_from: nil, &child_approval_wait)
9
+ @agent, @execution_id, @task = agent, execution_id, task
10
+ @environment = agent.__execution_environment
11
+ @resumed_from = resumed_from
12
+ @child_approval_wait = child_approval_wait
13
+ end
14
+
15
+ def wait_result(timeout: nil) = @task.wait_result(timeout: timeout)
16
+
17
+ # Supports racing normal completion or failure with an approval event.
18
+ def on_complete(&callback)
19
+ @task.on_complete(&callback)
20
+ self
21
+ end
22
+
23
+ def detach!(approval_request_id:, execution_id: @resumed_from&.execution_id || @execution_id)
24
+ if Phronomy::WaitPolicy.blocking_forbidden?
25
+ raise Phronomy::EventLoopReentrancyError, "detach! requires a host thread"
26
+ end
27
+ child = execution_id != @execution_id
28
+ wait = if @resumed_from
29
+ if execution_id == @resumed_from.execution_id && approval_request_id != @resumed_from.approval_request_id
30
+ @child_approval_wait.call(execution_id, approval_request_id)
31
+ end
32
+ elsif child
33
+ matching_child_wait(execution_id, approval_request_id)
34
+ else
35
+ @agent.persistence.standalone_approval_wait(agent_id: @agent.agent_id, execution_id: @execution_id)
36
+ end
37
+ unless wait && wait.approval_request_id == approval_request_id
38
+ raise Phronomy::AgentBusyError, "No matching hosted approval wait"
39
+ end
40
+ completion = Phronomy::TaskResult.deferred(name: "detach-hosted-approval")
41
+ owner = @environment.registry.agent_execution_owner(execution_id)
42
+ unless owner && (child ? owner.agent.persistence.equal?(@agent.persistence) : owner.agent.equal?(@agent))
43
+ raise Phronomy::AgentBusyError, "Hosted execution has no matching local owner"
44
+ end
45
+ command = ExecutionCoordinator::DetachHostedWait.new(coordinator: owner.coordinator, wait: wait,
46
+ task: (child && !@resumed_from) ? nil : @task, parent: child ? @agent : nil, completion: completion)
47
+ accepted = @environment.registry.post(command, completion: completion)
48
+ raise Phronomy::RuntimeShutdownError, "EventLoop rejected hosted detach" unless accepted
49
+ completion.wait_result
50
+ end
51
+
52
+ private
53
+
54
+ def matching_child_wait(execution_id, approval_request_id)
55
+ settled = @task.done?
56
+ wait = @child_approval_wait&.call(execution_id, approval_request_id)
57
+ return unless wait
58
+
59
+ # The child's event can precede the parent's committed coordination
60
+ # wait. Physical completion and that exact outcome authorize release.
61
+ @task.join
62
+ unless @task.coordination_wait_execution_id == @execution_id && @task.physical_complete?
63
+ @task.wait_result # Propagate a parent failure other than a committed wait.
64
+ raise Phronomy::AgentBusyError, "Hosted parent did not reach its coordination wait"
65
+ end
66
+ wait if settled || @child_approval_wait.call(execution_id, approval_request_id) == wait
67
+ end
68
+ end
69
+ end
70
+ end
@@ -29,6 +29,36 @@ module Phronomy
29
29
 
30
30
  private
31
31
 
32
+ # The participant proves which durable retentions are finished history;
33
+ # Agent controls admission, idleness and release of the local owners.
34
+ def __unload_with_peer_execution!(execution_id, reserved_by:)
35
+ if Phronomy::WaitPolicy.blocking_forbidden?
36
+ raise Phronomy::EventLoopReentrancyError, "Unloading coordinated Agents requires a host thread"
37
+ end
38
+ return false if @_phronomy_runtime_owner_state == :unloaded
39
+ __assert_live_agent!
40
+ peer = persistence.execution_identity(execution_id)
41
+ unless peer.correlation&.slice(*reserved_by.keys) == reserved_by
42
+ raise Phronomy::AgentBusyError, "Execution does not belong to this coordination owner"
43
+ end
44
+ environment = __execution_environment
45
+ environment.ownership.unload_idle_pair(self, peer.agent_id) do |live_peer|
46
+ ids = [agent_id, peer.agent_id].sort
47
+ if ids.any? { |id| environment.existing_registry&.agent_execution_admitted?(id) }
48
+ raise Phronomy::AgentBusyError, "A coordinated Agent has a local execution"
49
+ end
50
+ persistence.transaction do |records, scope|
51
+ unless scope.outermost?
52
+ raise Phronomy::Persistence::TransactionError, "Releasing local owners requires an outermost transaction"
53
+ end
54
+ ids.each { |id| records.executions.assert_idle!(id) }
55
+ unless yield(peer, live_peer, scope)
56
+ raise Phronomy::AgentBusyError, "No matching completed coordination history"
57
+ end
58
+ end
59
+ end
60
+ end
61
+
32
62
  def __with_operation(async: false, read_only: false)
33
63
  lease = __acquire_operation!(read_only: read_only)
34
64
  followed = false
@@ -20,6 +20,13 @@ module Phronomy
20
20
  ownership&.first&.release_operation(ownership[1], ownership[2])
21
21
  end
22
22
 
23
+ def reserve_approval_detach!(transition, parent: nil)
24
+ @mutex.synchronize do
25
+ raise Phronomy::AgentBusyError, "Hosted operation has ended" unless @registry
26
+ @registry.reserve_approval_detach!(@agent, @token, transition, parent: parent)
27
+ end
28
+ end
29
+
23
30
  def follow(task)
24
31
  if task.respond_to?(:on_physical_complete)
25
32
  task.on_physical_complete { release }
@@ -6,6 +6,9 @@ module Phronomy
6
6
  # Only the domain runner acknowledges the end of its accepted operation.
7
7
  # @api private
8
8
  class OperationResult < Phronomy::Concurrency::PhysicalCompletionTask
9
+ # Published on the EventLoop before the task signals a committed coordination wait.
10
+ attr_accessor :coordination_wait_execution_id
11
+
9
12
  def initialize(agent:, name:)
10
13
  super(name: name)
11
14
  @operation_lease = agent.send(:__acquire_operation!)
@@ -20,6 +23,15 @@ module Phronomy
20
23
  release_if_quiet
21
24
  end
22
25
 
26
+ def reserve_approval_detach!(transition, parent: nil)
27
+ @lifetime_mutex.synchronize do
28
+ if @finished || @released || !@work.zero?
29
+ raise Phronomy::AgentBusyError, "Hosted operation is still busy or has finished"
30
+ end
31
+ @operation_lease.reserve_approval_detach!(transition, parent: parent)
32
+ end
33
+ end
34
+
23
35
  def track(task)
24
36
  @lifetime_mutex.synchronize { @work += 1 }
25
37
  task.on_physical_complete do
@@ -165,12 +165,38 @@ module Phronomy
165
165
  true
166
166
  end
167
167
 
168
+ # Transfer only the single outstanding hosting operation, atomically with
169
+ # the same admission gate used by all other Agent operations.
170
+ def reserve_approval_detach!(agent, operation_token, transition, parent: nil)
171
+ @mutex.synchronize do
172
+ ensure_running!
173
+ entry = @entries[agent.agent_id]
174
+ operations = @operations[agent]
175
+ unless entry&.state == :live && entry.agent.equal?(agent) && !@scope_owners[agent.agent_id] &&
176
+ operations&.size == 1 && operations.key?(operation_token)
177
+ raise Phronomy::AgentBusyError, "Agent has another owner or accepted operation"
178
+ end
179
+ if parent
180
+ entry = @entries[parent.agent_id]
181
+ unless entry&.state == :live && entry.agent.equal?(parent) && !@scope_owners[parent.agent_id]
182
+ raise Phronomy::AgentBusyError, "Hosted parent no longer owns the local Agent"
183
+ end
184
+ assert_no_operations!(parent)
185
+ @entries[parent.agent_id] = Entry.new(state: :unloading, agent: parent, token: transition)
186
+ end
187
+ @entries[agent.agent_id] = Entry.new(state: :unloading, agent: agent, token: transition)
188
+ end
189
+ end
190
+
168
191
  # Returns the already accepted transition for duplicate callers.
169
192
  def reserve_unload(agent)
170
193
  @mutex.synchronize do
171
194
  ensure_running!
172
195
  entry = @entries[agent.agent_id]
173
196
  if entry&.state == :unloading && entry.agent.equal?(agent)
197
+ unless entry.token.is_a?(UnloadTransition)
198
+ raise Phronomy::AgentBusyError, "Agent has a coordinated release in progress"
199
+ end
174
200
  return [entry.token, false]
175
201
  end
176
202
  unless entry&.state == :live && entry.agent.equal?(agent)
@@ -183,22 +209,28 @@ module Phronomy
183
209
  end
184
210
  end
185
211
 
186
- def finish_unload(agent, transition, success:)
187
- @mutex.synchronize do
188
- entry = @entries[agent.agent_id]
189
- unless entry&.state == :unloading && entry.agent.equal?(agent) && entry.token.equal?(transition)
190
- raise Phronomy::RuntimeShutdownError, "Agent unload reservation is no longer authoritative"
191
- end
192
- if success
193
- agent.send(:__mark_unloaded!)
194
- @entries.delete(agent.agent_id)
195
- @scope_owners.delete(agent.agent_id)
196
- else
197
- @entries[agent.agent_id] = Entry.new(state: :live, agent: agent, token: nil)
212
+ def finish_unload(agent, transition, success:, parent: nil)
213
+ owners = {agent.agent_id => agent}
214
+ owners[parent.agent_id] = parent if parent
215
+ finish_unload_entries(owners, transition, success: success)
216
+ end
217
+
218
+ # Reserve the peer identity even when no instance is loaded. Its absence
219
+ # is then stable until the caller's durable checks commit or roll back.
220
+ def unload_idle_pair(parent, peer_id)
221
+ raise Phronomy::AgentBusyError, "Expected distinct Agent identities" if parent.agent_id == peer_id
222
+ transition = Object.new.freeze
223
+ # A host thread may exit during verification. Keep the short two-owner
224
+ # registry changes indivisible even in the presence of an async interrupt.
225
+ Thread.handle_interrupt(Object => :never) do
226
+ owners = reserve_idle_pair!(parent, peer_id, transition)
227
+ begin
228
+ Thread.handle_interrupt(Object => :immediate) { yield owners[peer_id] }
229
+ success = true
230
+ ensure
231
+ finish_unload_entries(owners, transition, success: success)
198
232
  end
199
- @condition.broadcast
200
233
  end
201
- true
202
234
  end
203
235
 
204
236
  def owned?(agent)
@@ -319,6 +351,46 @@ module Phronomy
319
351
 
320
352
  private
321
353
 
354
+ def reserve_idle_pair!(parent, peer_id, transition)
355
+ @mutex.synchronize do
356
+ ensure_running!
357
+ owners = {parent.agent_id => parent, peer_id => @entries[peer_id]&.agent}
358
+ owners.each do |id, agent|
359
+ entry = @entries[id]
360
+ raise Phronomy::AgentBusyError, "Agent belongs to a scope" if @scope_owners[id]
361
+ next if entry.nil? && agent.nil?
362
+ unless entry&.state == :live && entry.agent.equal?(agent)
363
+ raise Phronomy::AgentBusyError, "Local Agent owner changed"
364
+ end
365
+ assert_no_operations!(agent)
366
+ end
367
+ owners.each { |id, agent| @entries[id] = Entry.new(state: :unloading, agent: agent, token: transition) }
368
+ owners
369
+ end
370
+ end
371
+
372
+ def finish_unload_entries(owners, transition, success:)
373
+ @mutex.synchronize do
374
+ owners.each do |id, owner|
375
+ entry = @entries[id]
376
+ unless entry&.state == :unloading && entry.agent.equal?(owner) && entry.token.equal?(transition)
377
+ raise Phronomy::RuntimeShutdownError, "Agent unload reservation is no longer authoritative"
378
+ end
379
+ end
380
+ owners.each do |id, owner|
381
+ if success || owner.nil?
382
+ owner&.send(:__mark_unloaded!)
383
+ @entries.delete(id)
384
+ @scope_owners.delete(id)
385
+ else
386
+ @entries[id] = Entry.new(state: :live, agent: owner, token: nil)
387
+ end
388
+ end
389
+ @condition.broadcast
390
+ end
391
+ true
392
+ end
393
+
322
394
  def reserve_create!(key, scope_owner: nil)
323
395
  token = Object.new.freeze
324
396
  @mutex.synchronize do
@@ -9,13 +9,17 @@ module Phronomy
9
9
  class OutputContract
10
10
  FACTORIES = {}
11
11
  private_constant :FACTORIES
12
- attr_reader :schema, :digest
12
+ attr_reader :schema, :digest, :schema_delivery
13
13
 
14
14
  def self.install_factory(factory)
15
15
  FACTORIES.replace(parser: factory).freeze
16
16
  end
17
17
 
18
- def initialize(schema)
18
+ def initialize(schema, schema_delivery: :native)
19
+ unless %i[native none].include?(schema_delivery)
20
+ raise ArgumentError, "schema_delivery must be :native or :none"
21
+ end
22
+ @schema_delivery = schema_delivery
19
23
  @parser = FACTORIES.fetch(:parser).call(schema)
20
24
  @schema = @parser.json_schema
21
25
  @digest = Digest::SHA256.hexdigest(JSON.generate(@schema, sort_keys: true)).freeze
@@ -23,20 +27,58 @@ module Phronomy
23
27
  end
24
28
 
25
29
  def metadata(contents)
26
- {"output_contract" => {"version" => 1, "schema_ref" => contents.put_text(JSON.generate(schema, sort_keys: true)), "schema_digest" => digest}}
30
+ {"output_contract" => {"version" => 2, "schema_ref" => contents.put_text(JSON.generate(schema, sort_keys: true)),
31
+ "schema_digest" => digest, "schema_delivery" => schema_delivery.to_s}}
27
32
  end
28
33
 
29
34
  def self.restore(execution, contents)
30
35
  stored = execution.metadata["output_contract"]
31
36
  return nil unless stored
32
- raise ArgumentError, "unsupported Agent output contract version" unless stored.fetch("version") == 1
33
- contract = new(contents.fetch_json(stored.fetch("schema_ref")))
37
+ contract = new(contents.fetch_json(stored.fetch("schema_ref")), schema_delivery: delivery(stored))
34
38
  unless contract.digest == stored.fetch("schema_digest")
35
39
  raise Phronomy::ContentStore::IntegrityError, "Agent output schema digest mismatch"
36
40
  end
37
41
  contract
38
42
  end
39
43
 
44
+ # The schema stays durable even when the provider receives no schema.
45
+ def self.response_schema_ref(metadata)
46
+ stored = metadata["output_contract"]
47
+ stored.fetch("schema_ref") if stored && delivery(stored) == :native
48
+ end
49
+
50
+ def self.delivery(stored)
51
+ case stored.fetch("version")
52
+ when 1 then :native
53
+ when 2
54
+ value = stored.fetch("schema_delivery")
55
+ unless %w[native none].include?(value)
56
+ raise ArgumentError, "unsupported Agent schema delivery"
57
+ end
58
+ value.to_sym
59
+ else
60
+ raise ArgumentError, "unsupported Agent output contract version"
61
+ end
62
+ end
63
+
64
+ # Agent owns the final-output contract, including bypass prevention when
65
+ # no native schema is sent. Provider configuration cannot replace it.
66
+ def self.validate_model_config!(value)
67
+ case value
68
+ when Hash
69
+ value.each do |key, child|
70
+ if %w[schema schema_delivery output_schema response_schema response_format responseJsonSchema responseSchema
71
+ text.format output_config.format].include?(key.to_s) ||
72
+ (%w[text output_config].include?(key.to_s) && child.is_a?(Hash) && child.keys.any? { |name| name.to_s == "format" })
73
+ raise ArgumentError, "model configuration cannot override the Agent output contract"
74
+ end
75
+ validate_model_config!(child)
76
+ end
77
+ when Array
78
+ value.each { |child| validate_model_config!(child) }
79
+ end
80
+ end
81
+
40
82
  def validate(output, response:, execution_id:)
41
83
  raw = response&.content
42
84
  reason = if response&.refusal || response&.finish_reason == :refusal
@@ -12,6 +12,7 @@ module Phronomy
12
12
  AGENT_ROOT_FORMAT_VERSION = "0.1"
13
13
  AGENT_EXECUTION_RECORD_TYPE = "phronomy.agent_execution"
14
14
  AGENT_EXECUTION_FORMAT_VERSION = "0.2"
15
+ OUTPUT_CONTRACT_FORMAT_VERSION = "0.3"
15
16
  JOURNAL_RECORD_TYPE = "phronomy.journal_record"
16
17
  JOURNAL_FORMAT_VERSION = "0.1"
17
18
  AGENT_ROOT_KEYS = %w[
@@ -88,7 +89,7 @@ module Phronomy
88
89
  validate_agent_execution_payload!(payload)
89
90
  build_record(
90
91
  AGENT_EXECUTION_RECORD_TYPE,
91
- AGENT_EXECUTION_FORMAT_VERSION,
92
+ execution_format_version(payload),
92
93
  payload
93
94
  )
94
95
  rescue Phronomy::Storage::SerializationError
@@ -101,11 +102,15 @@ module Phronomy
101
102
  payload = current_payload!(
102
103
  record,
103
104
  record_type: AGENT_EXECUTION_RECORD_TYPE,
104
- format_version: AGENT_EXECUTION_FORMAT_VERSION,
105
+ format_version: (record.respond_to?(:format_version) && record.format_version == OUTPUT_CONTRACT_FORMAT_VERSION) ?
106
+ OUTPUT_CONTRACT_FORMAT_VERSION : AGENT_EXECUTION_FORMAT_VERSION,
105
107
  keys: AGENT_EXECUTION_KEYS,
106
108
  label: "AgentExecution payload"
107
109
  )
108
110
  validate_agent_execution_payload!(payload)
111
+ unless record.format_version == execution_format_version(payload)
112
+ raise Phronomy::Storage::SerializationError, "Agent output contract does not match execution format version"
113
+ end
109
114
  Phronomy::Agent::AgentExecution.from_h(payload)
110
115
  rescue Phronomy::Storage::SerializationError
111
116
  raise
@@ -113,6 +118,15 @@ module Phronomy
113
118
  serialization_error("cannot decode AgentExecution", error)
114
119
  end
115
120
 
121
+ # Old readers reject the new envelope before interpreting any execution.
122
+ # Freeform and version-one records retain their original envelope.
123
+ def execution_format_version(payload)
124
+ stored = payload.fetch("metadata")["output_contract"]
125
+ return AGENT_EXECUTION_FORMAT_VERSION unless stored
126
+ Phronomy::Agent::OutputContract.delivery(stored)
127
+ (stored.fetch("version") == 2) ? OUTPUT_CONTRACT_FORMAT_VERSION : AGENT_EXECUTION_FORMAT_VERSION
128
+ end
129
+
116
130
  def encode_journal_record(journal_record)
117
131
  payload = journal_payload(journal_record, require_sequence: true)
118
132
  build_record(JOURNAL_RECORD_TYPE, JOURNAL_FORMAT_VERSION, payload)
@@ -28,6 +28,7 @@ module Phronomy
28
28
  # operation-local projections transferred to the new invocation, never a
29
29
  # shared cache or a second source of continuation decisions.
30
30
  def prepare_recovery_material(execution, projection: nil)
31
+ OutputContract.restore(execution, agent.persistence.contents)
31
32
  action = recovery_action(execution)
32
33
  suspended = execution.status == :suspended || execution.phase.to_sym == :resuming
33
34
  return if !suspended && %i[resolution_required failed_terminal].include?(action)
@@ -316,7 +316,7 @@ module Phronomy
316
316
  main = agent.send(:execution_coordinator_for, agent.__execution_wiring)
317
317
 
318
318
  if framework_batch?(execution)
319
- internal = Phronomy::TaskResult.deferred(name: "framework-tool-recovery:#{execution.execution_id}")
319
+ internal = ExecutionCoordinator::FrameworkRecoveryTask.deferred(name: "framework-tool-recovery:#{execution.execution_id}")
320
320
  continue_recovery_on_event_loop(execution, internal, material: material)
321
321
  agent.send(:_complete_result_task, completion, agent)
322
322
  return
@@ -9,8 +9,8 @@ module Phronomy
9
9
  def self.build(invocation:, environment:, event_loop:, resume_event: nil, resume_phase: nil)
10
10
  event_sink = Phronomy::FSMSession::EventSink.new(event_loop: event_loop)
11
11
  invocation.bind_event_sink!(event_sink)
12
- actions = InvocationActions.build_entry_actions(invocation.agent,
13
- environment, mode: invocation.mode, event_sink: event_sink)
12
+ actions = InvocationActions.build_entry_actions(environment,
13
+ mode: invocation.mode, event_sink: event_sink)
14
14
  phase_machine = PhaseMachineBuilder.new(entry_actions: actions).build
15
15
 
16
16
  Phronomy::FSMSession.new(
@@ -54,6 +54,13 @@ module Phronomy
54
54
  )
55
55
  end
56
56
 
57
+ # One interpretation for execution results and public transcript reads.
58
+ def materialize_transcript(root:, records:)
59
+ materialize_journal_records(
60
+ JournalProjection.new(agent_root: root, records: records).transcript_records
61
+ )
62
+ end
63
+
57
64
  def materialize_journal_record(record)
58
65
  materialize_segment(segment_from_record(record))
59
66
  end
@@ -108,6 +108,58 @@ module Phronomy
108
108
  end
109
109
  end
110
110
 
111
+ # Read without loading a live Agent. Missing or ineligible waits return nil.
112
+ def standalone_approval_wait(agent_id:, execution_id:, scope: nil)
113
+ approval_wait_for(agent_id: agent_id, execution_id: execution_id, correlation: nil, retentions: [], scope: scope)
114
+ end
115
+
116
+ # Participant observation. An active coordinated wait must match both its
117
+ # exact reservation and every durable retention owned by the participant.
118
+ # @api private
119
+ def approval_wait_for(agent_id:, execution_id:, correlation:, retentions:, scope:)
120
+ with_scope(scope) do |records|
121
+ begin
122
+ execution = records.executions.load(execution_id)
123
+ rescue Phronomy::Persistence::NotFoundError
124
+ next
125
+ end
126
+ assert_execution_owner!(execution, agent_id)
127
+ next unless execution.status == :suspended && (id = execution.approval_request&.fetch("id", nil))
128
+ next unless execution.metadata["reservation"] == correlation && !execution.metadata["execution_extension"]
129
+ next if execution.metadata["cancellation_requested"] || records.cancellations.requested?(agent_id, execution_id)
130
+ next unless records.retentions.list(agent_id) == retentions
131
+
132
+ ApprovalWait.new(execution_id: execution.execution_id.dup.freeze, approval_request_id: id.dup.freeze,
133
+ execution_revision: execution.execution_revision)
134
+ end
135
+ end
136
+
137
+ # A native participant can release a root only at a single replayable
138
+ # Tool dispatch. A mixed batch may still require external Tool evidence.
139
+ # @api private
140
+ def single_dispatch_wait?(agent_id:, execution_id:, invocation_id:, name:, scope:, expected_revision: nil)
141
+ with_scope(scope) do |records|
142
+ execution = records.executions.load(execution_id)
143
+ assert_execution_owner!(execution, agent_id)
144
+ next false if expected_revision && execution.execution_revision != expected_revision
145
+ batch = Array(execution.metadata[ExecutionMetadata::TOOL_BATCH_METADATA_KEY])
146
+ execution.status == :active && execution.phase.to_sym == :dispatching_tools &&
147
+ !execution.metadata["reservation"] && !execution.metadata["cancellation_requested"] &&
148
+ !records.cancellations.requested?(agent_id, execution_id) &&
149
+ batch.size == 1 && batch.first.values_at("tool_invocation_id", "tool_name", "status") == [invocation_id, name, "authorized"]
150
+ end
151
+ end
152
+
153
+ # Check a historical reference without reading its output or error content.
154
+ # @api private
155
+ def terminal_execution?(agent_id:, execution_id:, scope:, reservation: nil)
156
+ with_scope(scope) do |records|
157
+ execution = records.executions.load(execution_id)
158
+ assert_execution_owner!(execution, agent_id)
159
+ execution.terminal? && (reservation.nil? || execution.metadata["reservation"] == reservation)
160
+ end
161
+ end
162
+
111
163
  def active_executions(agent_id:, scope: nil)
112
164
  with_scope(scope) do |records|
113
165
  records.executions.list_active(agent_id).map do |execution|