phronomy 0.30.1 → 0.31.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +7 -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/getting-started.md +26 -0
  9. data/docs/migrations/0.31.md +72 -0
  10. data/lib/phronomy/agent/async_event_api.rb +17 -8
  11. data/lib/phronomy/agent/base.rb +27 -2
  12. data/lib/phronomy/agent/composition/run_once.rb +11 -10
  13. data/lib/phronomy/agent/context_preparation.rb +1 -0
  14. data/lib/phronomy/agent/execution/dispatch_preparation.rb +1 -1
  15. data/lib/phronomy/agent/execution/exact_execution.rb +5 -3
  16. data/lib/phronomy/agent/execution/execution_coordinator.rb +6 -0
  17. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -6
  18. data/lib/phronomy/agent/execution/initial_preparation.rb +1 -1
  19. data/lib/phronomy/agent/execution/invocation_actions.rb +17 -15
  20. data/lib/phronomy/agent/execution_change.rb +4 -0
  21. data/lib/phronomy/agent/output_contract.rb +47 -5
  22. data/lib/phronomy/agent/persistence/codec.rb +16 -2
  23. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +1 -0
  24. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -2
  25. data/lib/phronomy/agent/runtime_input.rb +7 -0
  26. data/lib/phronomy/engine/fsm_session.rb +7 -0
  27. data/lib/phronomy/execution/async_operation.rb +16 -0
  28. data/lib/phronomy/execution/concurrency/result_finalization.rb +106 -0
  29. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +3 -3
  30. data/lib/phronomy/generation/generator_verifier.rb +6 -3
  31. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +1 -1
  32. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +3 -1
  33. data/lib/phronomy/tools/agent.rb +58 -22
  34. data/lib/phronomy/version.rb +1 -1
  35. data/sig/phronomy/agent.rbs +7 -5
  36. data/sig/phronomy/execution/async_operation.rbs +1 -0
  37. data/sig/phronomy/generation/generator_verifier.rbs +1 -1
  38. data/sig/phronomy/tools/agent.rbs +14 -0
  39. metadata +6 -2
@@ -400,6 +400,31 @@ module Phronomy
400
400
  end
401
401
  end
402
402
 
403
+ # Materializes the current durable transcript as immutable Message values.
404
+ # Application authorization and presentation filtering remain caller-owned.
405
+ # @return [Array<LLMAdapter::Message>]
406
+ # @api public
407
+ def transcript_messages
408
+ _check_event_loop_reentrancy(:transcript_messages, :transcript_messages_async)
409
+ transcript_messages_async.wait_result
410
+ end
411
+
412
+ # Holds Agent ownership until the submitted read physically completes.
413
+ # @return [TaskResult<Array<LLMAdapter::Message>>]
414
+ # @api public
415
+ def transcript_messages_async
416
+ __with_operation(async: true, read_only: true) do
417
+ __execution_environment.submit(on_full: :raise) do
418
+ root, records = persistence.transaction do |tx|
419
+ current = tx.agents.load(agent_id)
420
+ [current, tx.journals.read(agent_id, limit: current.journal_position)]
421
+ end
422
+ RuntimeInput.new(agent: self, persistence: persistence)
423
+ .materialize_transcript(root: root, records: records)
424
+ end
425
+ end
426
+ end
427
+
403
428
  def clear_transcript!
404
429
  __with_operation do
405
430
  mutate_context!(:transcript_cleared) do |root|
@@ -730,8 +755,8 @@ module Phronomy
730
755
  def _prepare_invocation_config(config, invocation_context)
731
756
  __assert_live_agent!
732
757
  _reject_removed_generic_identity_keys!(config)
733
- if config.keys.any? { |key| %w[output_schema response_schema phronomy_output_contract].include?(key.to_s) }
734
- raise ArgumentError, "Use the output_schema keyword on a new Agent execution"
758
+ if config.keys.any? { |key| %w[output_schema schema_delivery response_schema phronomy_output_contract].include?(key.to_s) }
759
+ raise ArgumentError, "Use the output_schema and schema_delivery keywords on a new Agent execution"
735
760
  end
736
761
  invocation_context ?
737
762
  config.merge(invocation_context: invocation_context) : config
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This convenience API composes a fresh ephemeral Persistence with an Agent.
3
+ # This convenience API owns a fresh ephemeral Persistence and a scoped Agent.
4
4
  # The application entry loads the method definition; Agent execution does not
5
5
  # delegate to or require this higher-level composition implementation.
6
6
  module Phronomy
@@ -18,15 +18,16 @@ module Phronomy
18
18
  raise ArgumentError, "Provide either on_event: or a block, not both"
19
19
  end
20
20
 
21
- persistence = Phronomy::PersistenceComposition.agent
22
- agent = definition.create(
23
- context: context,
24
- knowledge: knowledge,
25
- persistence: persistence,
26
- on_event: on_event,
27
- &event_block
28
- )
29
- agent.invoke(input, **invoke_options)
21
+ open_scope do |scope|
22
+ persistence = Phronomy::PersistenceComposition.agent
23
+ agent = scope.create(definition,
24
+ context: context,
25
+ knowledge: knowledge,
26
+ persistence: persistence,
27
+ on_event: on_event,
28
+ &event_block)
29
+ agent.invoke(input, **invoke_options)
30
+ end
30
31
  end
31
32
  end
32
33
  end
@@ -251,6 +251,7 @@ module Phronomy
251
251
  tools:,
252
252
  current_input:
253
253
  )
254
+ OutputContract.validate_model_config!(model_config) if execution.metadata.key?("output_contract")
254
255
  token_budget = Phronomy::Agent::Settings.current.llm_adapter.input_budget(model_config)
255
256
  policy_input = @input_builder.build(
256
257
  agent_id: agent_root.agent_id,
@@ -123,7 +123,7 @@ module Phronomy
123
123
  manifest, manifest_ref = @persistence.transaction do |tx|
124
124
  assert_local_durable_base!(tx, operation.root)
125
125
  Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
126
- response_schema_ref: recorded.metadata.dig("output_contract", "schema_ref"))
126
+ response_schema_ref: OutputContract.response_schema_ref(recorded.metadata))
127
127
  end
128
128
  updated = execution_with_provider_manifest(recorded, manifest_ref)
129
129
  change = ExecutionChange.new(agent: @agent, current: operation.execution,
@@ -122,9 +122,11 @@ module Phronomy
122
122
  raise Phronomy::Persistence::StateConflictError, "Reserved execution #{@id} correlation mismatch"
123
123
  end
124
124
  unless @resume_only
125
- expected_digest = @config[:phronomy_output_contract]&.digest
126
- if expected_digest != execution.metadata.dig("output_contract", "schema_digest")
127
- raise Phronomy::Persistence::StateConflictError, "Reserved execution output schema mismatch"
125
+ expected_contract = @config[:phronomy_output_contract]
126
+ saved_contract = OutputContract.restore(execution, @agent.persistence.contents)
127
+ if expected_contract&.digest != saved_contract&.digest ||
128
+ expected_contract&.schema_delivery != saved_contract&.schema_delivery
129
+ raise Phronomy::Persistence::StateConflictError, "Reserved execution output contract mismatch"
128
130
  end
129
131
  end
130
132
  ref = execution.metadata["current_input_ref"]
@@ -1703,6 +1703,12 @@ module Phronomy
1703
1703
  execution_id,
1704
1704
  fallback: fallback_task
1705
1705
  )
1706
+ # All these waiters observe the same terminal execution. A synchronous
1707
+ # completion callback may request unload immediately, so acknowledge
1708
+ # every finished operation before publishing any logical result. Each
1709
+ # OperationResult still retains its lease until tracked physical work
1710
+ # ends; unrelated operations remain protected by their own leases.
1711
+ waiters.each { |task| task.finish_operation! if task.respond_to?(:finish_operation!) }
1706
1712
  waiters.each do |task|
1707
1713
  settle_after_terminal(
1708
1714
  task, callback_error, event_type, result, execution_error
@@ -286,12 +286,7 @@ module Phronomy
286
286
  agent: @agent,
287
287
  persistence: persistence
288
288
  )
289
- materializer.materialize_journal_records(
290
- JournalProjection.new(
291
- agent_root: root,
292
- records: journal_records
293
- ).transcript_records
294
- )
289
+ materializer.materialize_transcript(root: root, records: journal_records)
295
290
  end
296
291
 
297
292
  def result_base(execution, root)
@@ -192,7 +192,7 @@ module Phronomy
192
192
  @persistence.transaction do |tx|
193
193
  assert_local_durable_base!(tx, root)
194
194
  manifest, manifest_ref = Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
195
- response_schema_ref: staged.metadata.dig("output_contract", "schema_ref"))
195
+ response_schema_ref: OutputContract.response_schema_ref(staged.metadata))
196
196
  active = execution_with_initial_manifest(staged, manifest_ref)
197
197
  tx.executions.save(
198
198
  staged.execution_id,
@@ -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
@@ -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)
@@ -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
@@ -165,6 +165,10 @@ module Phronomy
165
165
  end
166
166
  rescue => error
167
167
  finish_with_error(error)
168
+ ensure
169
+ # The session owns its context between events. A tracker retained by the
170
+ # state-machine library or VM must not retain that live context as well.
171
+ @tracker.context = nil if @tracker
168
172
  end
169
173
 
170
174
  def handle(event)
@@ -190,6 +194,9 @@ module Phronomy
190
194
  fire_and_advance!(event)
191
195
  rescue => error
192
196
  finish_with_error(error)
197
+ ensure
198
+ # fire_and_advance! rebinds the context for the next accepted transition.
199
+ @tracker.context = nil if @tracker
193
200
  end
194
201
 
195
202
  private
@@ -25,6 +25,22 @@ module Phronomy
25
25
  map(source, name: name, on_error: on_error, &transform)
26
26
  end
27
27
 
28
+ # Finalize owned work after its logical and physical boundaries. Cleanup is
29
+ # asynchronous too. The owner supplies cleanup and combined-error meaning;
30
+ # neither observer cancellation nor an Execution scope can skip cleanup.
31
+ # @api private
32
+ def self.finalize(name:, cleanup:, on_cleanup_error:)
33
+ source = begin
34
+ value = yield
35
+ raise TypeError, "finalized operation must return a TaskResult" unless value.is_a?(TaskResult)
36
+ value
37
+ rescue => error
38
+ TaskResult.failed(error, name: name)
39
+ end
40
+ Concurrency::ResultFinalization.new(source, name: name,
41
+ cleanup: cleanup, on_cleanup_error: on_cleanup_error).start
42
+ end
43
+
28
44
  # Short synchronous fallback/validation only; never offloads or awaits work.
29
45
  def self.capture(name:, on_error: nil)
30
46
  value = yield
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # Completion composition for an owner-supplied asynchronous finalizer.
6
+ # This has no cancellation authority and no domain release policy.
7
+ # @api private
8
+ class ResultFinalization
9
+ def initialize(source, name:, cleanup:, on_cleanup_error:)
10
+ @source = source
11
+ @cleanup = cleanup
12
+ @on_cleanup_error = on_cleanup_error
13
+ @result = PhysicalCompletionTask.deferred(name: name)
14
+ @mutex = Mutex.new
15
+ @phase = :source
16
+ @outcomes = {}
17
+ @physical = {}
18
+ end
19
+
20
+ def start
21
+ observe(@source, :source)
22
+ @result
23
+ end
24
+
25
+ private
26
+
27
+ def observe(task, phase)
28
+ physical = task.respond_to?(:on_physical_complete)
29
+ @mutex.synchronize { @physical[phase] = !physical }
30
+ if physical
31
+ task.on_physical_complete do
32
+ @mutex.synchronize { @physical[phase] = true unless @phase == :finished }
33
+ advance
34
+ end
35
+ end
36
+ task.on_complete do |value, error|
37
+ @mutex.synchronize do
38
+ if @phase == phase && !@outcomes.key?(phase)
39
+ status = if error
40
+ (task.status == :cancelled) ? :cancelled : :failed
41
+ else
42
+ :completed
43
+ end
44
+ @outcomes[phase] = [status, value, error]
45
+ end
46
+ end
47
+ advance
48
+ end
49
+ end
50
+
51
+ def advance
52
+ action = @mutex.synchronize do
53
+ phase = @phase
54
+ next unless @outcomes.key?(phase) && @physical[phase]
55
+ @phase = (phase == :source) ? :cleanup : :finished
56
+ phase
57
+ end
58
+ case action
59
+ when :source then start_cleanup
60
+ when :cleanup then finish
61
+ end
62
+ end
63
+
64
+ def start_cleanup
65
+ task = begin
66
+ value = @cleanup.call
67
+ raise TypeError, "asynchronous cleanup must return a TaskResult" unless value.is_a?(TaskResult)
68
+ value
69
+ rescue => error
70
+ TaskResult.failed(error)
71
+ end
72
+ observe(task, :cleanup)
73
+ end
74
+
75
+ def finish
76
+ status, value, error = @outcomes.fetch(:source)
77
+ cleanup_error = @outcomes.fetch(:cleanup).last
78
+ if cleanup_error
79
+ begin
80
+ error = @on_cleanup_error.call(value, error, cleanup_error)
81
+ raise TypeError, "cleanup error policy must return or raise an Exception" unless error.is_a?(Exception)
82
+ rescue => policy_error
83
+ error = policy_error
84
+ end
85
+ status = :failed
86
+ end
87
+ # Completed handles must not keep the owner or its source/cleanup
88
+ # closures alive. A domain cleanup error may deliberately retain them.
89
+ @source = @cleanup = @on_cleanup_error = nil
90
+ @mutex.synchronize do
91
+ @outcomes.clear
92
+ @physical.clear
93
+ end
94
+ begin
95
+ @result.mark_physical_complete!
96
+ ensure
97
+ case status
98
+ when :completed then @result.complete(value)
99
+ when :cancelled then @result.cancel!(error)
100
+ else @result.fail(error)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -8,8 +8,8 @@ module Phronomy
8
8
  # still pending. Generation owns the request-to-Workflow correlation.
9
9
  # @api private
10
10
  class AgentOperation
11
- def initialize(agent_class, output_schema:)
12
- @output_schema = output_schema
11
+ def initialize(agent_class, output_schema:, schema_delivery:)
12
+ @output_schema, @schema_delivery = output_schema, schema_delivery
13
13
  @mutex = Mutex.new
14
14
  @pending = []
15
15
  @agent = agent_class.new(on_event: method(:receive_event))
@@ -21,7 +21,7 @@ module Phronomy
21
21
  # own result settles: Agent admission itself may run after an earlier
22
22
  # request completes, so a pending submission is not necessarily busy.
23
23
  @mutex.synchronize { @pending << [request, listener] }
24
- task = @agent.invoke_async(input, config: config, output_schema: @output_schema)
24
+ task = @agent.invoke_async(input, config: config, output_schema: @output_schema, schema_delivery: @schema_delivery)
25
25
  task.on_complete do |result, error|
26
26
  release(request)
27
27
  remember_execution(result[:execution_id]) if result.is_a?(Hash)
@@ -34,10 +34,13 @@ module Phronomy
34
34
  end
35
35
 
36
36
  def initialize(draft_agent:, review_agent:, draft_prompt_builder:, review_prompt_builder:,
37
- citation_schema: Protocol::DEFAULT_CITATION,
37
+ citation_schema: Protocol::DEFAULT_CITATION, schema_delivery: :native,
38
38
  confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
39
39
  max_iterations: DEFAULT_MAX_ITERATIONS, raise_if_unaccepted: false, on_approval: nil)
40
40
  ensure_blocking_allowed!(:new)
41
+ unless %i[native none].include?(schema_delivery)
42
+ raise ArgumentError, "schema_delivery must be :native or :none"
43
+ end
41
44
  unless confidence_threshold.is_a?(Numeric) && confidence_threshold.real? && confidence_threshold.to_f.finite? &&
42
45
  (0.0..1.0).cover?(confidence_threshold)
43
46
  raise ArgumentError, "confidence_threshold must be a finite number in 0..1"
@@ -62,8 +65,8 @@ module Phronomy
62
65
  @draft_prompt_builder, @review_prompt_builder = draft_prompt_builder, review_prompt_builder
63
66
  draft_schema = Protocol.draft(citation_schema)
64
67
  begin
65
- @operations[:draft] = AgentOperation.new(draft_agent, output_schema: draft_schema)
66
- @operations[:review] = AgentOperation.new(review_agent, output_schema: Protocol::REVIEW)
68
+ @operations[:draft] = AgentOperation.new(draft_agent, output_schema: draft_schema, schema_delivery: schema_delivery)
69
+ @operations[:review] = AgentOperation.new(review_agent, output_schema: Protocol::REVIEW, schema_delivery: schema_delivery)
67
70
  rescue Exception => primary # rubocop:disable Lint/RescueException -- release partially acquired resources
68
71
  begin
69
72
  release!
@@ -63,7 +63,7 @@ module Phronomy
63
63
  callback_error = nil
64
64
  chat = build_chat(request)
65
65
  capture = StructuredResponseCapture.new
66
- capture.bind(chat) if request.response_schema && block_given?
66
+ capture.bind(chat) if block_given?
67
67
  token&.raise_if_cancelled!
68
68
  chat.after_message do |message|
69
69
  raise ToolsRequested.new(message) if message.tool_call?
@@ -24,7 +24,9 @@ module Phronomy
24
24
 
25
25
  def record(data)
26
26
  choice = data.dig("choices", 0) || {}
27
- @raw_finish_reason = choice["finish_reason"] || data.dig("delta", "stop_reason") ||
27
+ provider_delta = data["delta"]
28
+ stop_reason = provider_delta["stop_reason"] if provider_delta.is_a?(Hash)
29
+ @raw_finish_reason = choice["finish_reason"] || stop_reason ||
28
30
  data.dig("candidates", 0, "finishReason") || @raw_finish_reason
29
31
  delta = choice.dig("delta", "refusal")
30
32
  delta ||= data["delta"] if data["type"] == "response.refusal.delta"