phronomy 0.29.0 → 0.30.1

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +17 -6
  4. data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
  5. data/docs/architecture/agent-iteration-budget.md +81 -0
  6. data/docs/architecture/dx05-agent-scope.md +9 -2
  7. data/docs/architecture/error-api-review.md +4 -4
  8. data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
  9. data/docs/architecture/generator-verifier-ownership.md +18 -109
  10. data/docs/architecture/llm-continuation-metadata.md +88 -0
  11. data/docs/architecture/lm-studio-output-failures.md +111 -0
  12. data/docs/architecture/r8-unit1.md +68 -68
  13. data/docs/architecture/r8-unit15.md +3 -0
  14. data/docs/architecture/r8-unit2.md +34 -34
  15. data/docs/architecture/r8-unit7.md +2 -1
  16. data/docs/architecture/structured-output-and-scope.md +183 -0
  17. data/docs/architecture/tracing.md +22 -4
  18. data/docs/architecture/transport-error-boundaries.md +103 -0
  19. data/docs/architecture/trust-acceptance-followup.md +87 -0
  20. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +1 -1
  21. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +23 -23
  22. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +99 -99
  23. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +135 -136
  24. data/docs/features.md +10 -1
  25. data/docs/migrations/r8-unit15.md +3 -0
  26. data/gemfiles/http_minimum.gemfile +6 -1
  27. data/lib/phronomy/agent/admission.rb +1 -1
  28. data/lib/phronomy/agent/async_event_api.rb +16 -7
  29. data/lib/phronomy/agent/base.rb +8 -1
  30. data/lib/phronomy/agent/context_importer.rb +4 -0
  31. data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
  32. data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
  33. data/lib/phronomy/agent/execution/exact_execution.rb +9 -0
  34. data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
  35. data/lib/phronomy/agent/execution/execution_coordinator.rb +63 -5
  36. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
  37. data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
  38. data/lib/phronomy/agent/execution/initial_preparation.rb +10 -1
  39. data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
  40. data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
  41. data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
  42. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +3 -0
  43. data/lib/phronomy/agent/execution_observation.rb +5 -5
  44. data/lib/phronomy/agent/lifecycle/scope.rb +33 -23
  45. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +4 -2
  46. data/lib/phronomy/agent/output_contract.rb +64 -0
  47. data/lib/phronomy/agent/output_error.rb +53 -0
  48. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
  49. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -0
  50. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +1 -1
  51. data/lib/phronomy/agent/recovery/recovery_support.rb +5 -1
  52. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
  53. data/lib/phronomy/agent/runtime_input.rb +15 -4
  54. data/lib/phronomy/agent/saved_context_reader.rb +2 -1
  55. data/lib/phronomy/agent/store.rb +10 -2
  56. data/lib/phronomy/common/values/immutable.rb +33 -0
  57. data/lib/phronomy/context/assembly.rb +7 -4
  58. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +3 -1
  59. data/lib/phronomy/engine/fsm_session.rb +16 -1
  60. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
  61. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
  62. data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
  63. data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
  64. data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
  65. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
  66. data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
  67. data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
  68. data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
  69. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
  70. data/lib/phronomy/generation/generator_verifier.rb +170 -85
  71. data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
  72. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
  73. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
  74. data/lib/phronomy/llm_adapter/message.rb +4 -0
  75. data/lib/phronomy/llm_adapter/request.rb +17 -2
  76. data/lib/phronomy/llm_adapter/response.rb +11 -4
  77. data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
  78. data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
  79. data/lib/phronomy/multi_agent/shared_state.rb +1 -0
  80. data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
  81. data/lib/phronomy/output_parser/parse_error.rb +10 -1
  82. data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
  83. data/lib/phronomy/tracing/automatic.rb +14 -8
  84. data/lib/phronomy/tracing/base.rb +2 -1
  85. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  86. data/lib/phronomy/tracing/observation.rb +2 -1
  87. data/lib/phronomy/tracing/open_telemetry_tracer.rb +3 -3
  88. data/lib/phronomy/version.rb +1 -1
  89. data/lib/phronomy.rb +1 -2
  90. data/scripts/api_snapshot.rb +3 -0
  91. data/scripts/migrate_generator_verifier_result.rb +18 -0
  92. data/sig/phronomy/agent/execution_contract.rbs +4 -3
  93. data/sig/phronomy/agent.rbs +20 -7
  94. data/sig/phronomy/extensions.rbs +8 -3
  95. data/sig/phronomy/generation/generator_verifier.rbs +70 -0
  96. data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
  97. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
  98. data/sig/phronomy/llm_adapter/values.rbs +8 -2
  99. data/sig/phronomy/tracing/observation.rbs +1 -1
  100. data/sig/phronomy.rbs +2 -0
  101. metadata +59 -8
  102. data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
  103. data/lib/phronomy/generation/low_confidence_error.rb +0 -14
  104. data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
  105. data/sig/phronomy/generation/low_confidence_error.rbs +0 -6
@@ -5,11 +5,6 @@ module Phronomy
5
5
  # Converts Agent events to correlated Workflow events. PipelineState owns
6
6
  # stale-result rejection and all Workflow state mutation.
7
7
  class AgentResultReceiver
8
- def initialize(draft_result_parser:, review_result_parser:)
9
- @draft_result_parser = draft_result_parser
10
- @review_result_parser = review_result_parser
11
- end
12
-
13
8
  def draft_listener(workflow:, workflow_instance_id:, request_id:)
14
9
  agent_listener(:draft, workflow, workflow_instance_id, request_id) do |output|
15
10
  draft_payload(output)
@@ -25,20 +20,20 @@ module Phronomy
25
20
  private
26
21
 
27
22
  def draft_payload(output)
28
- parsed = @draft_result_parser.call(output)
23
+ parsed = output
29
24
  {
30
- draft: parsed[:answer].to_s,
31
- self_score: clamp(parsed[:confidence]),
32
- citations: normalize_citations(parsed[:citations])
25
+ draft: parsed.fetch("answer"),
26
+ self_score: parsed.fetch("confidence"),
27
+ citations: parsed.fetch("citations")
33
28
  }
34
29
  end
35
30
 
36
31
  def review_payload(output)
37
- parsed = @review_result_parser.call(output)
32
+ parsed = output
38
33
  {
39
- review_score: clamp(parsed[:score]),
40
- approved: parsed[:approved] == true,
41
- feedback: parsed[:feedback].to_s
34
+ review_score: parsed.fetch("score"),
35
+ approved: parsed.fetch("approved"),
36
+ feedback: parsed.fetch("feedback")
42
37
  }
43
38
  end
44
39
 
@@ -47,6 +42,11 @@ module Phronomy
47
42
  case agent_event.type
48
43
  when :done
49
44
  begin
45
+ if agent_event.payload[:rejected]
46
+ workflow.signal(workflow_instance_id: workflow_instance_id,
47
+ event: :"#{phase}_rejected", payload: {request_id: request_id})
48
+ next
49
+ end
50
50
  payload = completion.call(agent_event.payload[:output])
51
51
  workflow.signal(
52
52
  workflow_instance_id: workflow_instance_id,
@@ -60,9 +60,6 @@ module Phronomy
60
60
  error = agent_event.payload[:error] ||
61
61
  Phronomy::Error.new("#{phase.to_s.capitalize} Agent ended with #{agent_event.type}")
62
62
  signal_failure(workflow, workflow_instance_id, request_id, phase, error)
63
- when :approval_required
64
- error = Phronomy::Error.new("GeneratorVerifier #{phase} Agent suspended for approval")
65
- signal_failure(workflow, workflow_instance_id, request_id, phase, error)
66
63
  end
67
64
  }
68
65
  end
@@ -70,20 +67,10 @@ module Phronomy
70
67
  def signal_failure(workflow, workflow_instance_id, request_id, phase, error)
71
68
  workflow.signal(
72
69
  workflow_instance_id: workflow_instance_id,
73
- event: :"#{phase}_failed",
70
+ event: error.is_a?(Phronomy::Agent::OutputError) ? :"#{phase}_output_failed" : :"#{phase}_failed",
74
71
  payload: {request_id: request_id, error: error}
75
72
  )
76
73
  end
77
-
78
- def clamp(value)
79
- value.to_f.clamp(0.0, 1.0)
80
- end
81
-
82
- def normalize_citations(raw)
83
- Array(raw).filter_map do |citation|
84
- citation.is_a?(Hash) ? citation.transform_keys(&:to_sym) : nil
85
- end
86
- end
87
74
  end
88
75
  end
89
76
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class GeneratorVerifier
5
+ # One application-visible Tool approval notification, scoped to an invocation.
6
+ # The C04 request carries display-safe details. Generation only routes replies.
7
+ class Approval
8
+ attr_reader :invocation_id, :phase, :request
9
+
10
+ def initialize(invocation:, operation:, phase:, request:)
11
+ @invocation, @operation = invocation, operation
12
+ @invocation_id, @phase, @request = invocation.id, phase, request
13
+ freeze
14
+ end
15
+
16
+ def respond_async(approved:)
17
+ unless approved == true || approved == false
18
+ return Phronomy::TaskResult.failed(ArgumentError.new("approved must be true or false"))
19
+ end
20
+ @invocation.respond(self) do
21
+ @operation.approve_async(@request, approved: approved)
22
+ end.map { nil }
23
+ end
24
+
25
+ def to_h
26
+ {invocation_id: @invocation_id, phase: @phase.to_s, request: @request.to_h}
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class GeneratorVerifier
5
+ class CleanupError < Phronomy::Error
6
+ attr_reader :pipeline, :failures, :result, :primary_error
7
+
8
+ def initialize(pipeline:, failures:, result: nil, primary_error: nil, code: "generation.cleanup_failed")
9
+ @pipeline, @result, @primary_error = pipeline, result, primary_error
10
+ @failures = failures.map { |failure| failure.dup.freeze }.freeze
11
+ super("Could not release #{failures.length} GeneratorVerifier Agent(s)", code: code)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Phronomy
6
+ class GeneratorVerifier
7
+ # Owns one run's subscriptions, child observations and application approval route.
8
+ # Agent owns durable execution, authorization and physical release safety.
9
+ # @api private
10
+ class Invocation
11
+ attr_reader :id, :result, :config
12
+
13
+ def initialize(config:, on_approval:, on_finish:)
14
+ @id = SecureRandom.uuid.freeze
15
+ @result = Phronomy::TaskResult.deferred(name: "generation:#{@id}")
16
+ @mutex = Mutex.new
17
+ @pending = 0
18
+ @workflow_done = false
19
+ @finished = false
20
+ @approvals = []
21
+ @on_approval, @on_finish = on_approval, on_finish
22
+ @token = Phronomy::Concurrency::CancellationToken.new
23
+ @subscriptions = Phronomy::Concurrency::Subscriptions.new
24
+ @config = config.merge(cancellation_token: @token).freeze
25
+ @agent_config = {cancellation_token: @token}
26
+ @agent_config[:invocation_context] = config[:invocation_context] if config.key?(:invocation_context)
27
+ @agent_config.freeze
28
+ @subscriptions.cancellation(config[:cancellation_token]) { @token.cancel! }
29
+ end
30
+
31
+ def approvals
32
+ @mutex.synchronize { @approvals.dup.freeze }
33
+ end
34
+
35
+ def cancel
36
+ @token.cancel!
37
+ end
38
+
39
+ def start(operation, phase, input, listener:)
40
+ observe do
41
+ @token.raise_if_cancelled!
42
+ operation.start(input, config: @agent_config, listener: ->(event) {
43
+ if event.type == :approval_required
44
+ notify_approval(operation, phase, event.payload.fetch(:request))
45
+ else
46
+ listener.call(event)
47
+ end
48
+ })
49
+ end
50
+ end
51
+
52
+ def respond(approval)
53
+ observe(check_approval: approval) { yield }
54
+ rescue => error
55
+ Phronomy::TaskResult.failed(error)
56
+ end
57
+
58
+ def follow(workflow_task, &build_result)
59
+ workflow_task.on_complete do |state, error|
60
+ begin
61
+ value = build_result.call(state) unless error
62
+ rescue => result_error
63
+ error = result_error
64
+ end
65
+ finish_workflow(value, error)
66
+ end
67
+ @result
68
+ end
69
+
70
+ def fail_start(error)
71
+ finish_workflow(nil, error)
72
+ @result
73
+ end
74
+
75
+ private
76
+
77
+ def notify_approval(operation, phase, request)
78
+ approval = Approval.new(invocation: self, operation: operation, phase: phase, request: request)
79
+ deliver = @mutex.synchronize do
80
+ next false if @workflow_done || @finished
81
+ @approvals << approval
82
+ true
83
+ end
84
+ @on_approval&.call(approval) if deliver
85
+ rescue => error
86
+ @mutex.synchronize { @primary_error ||= error }
87
+ cancel
88
+ end
89
+
90
+ def observe(check_approval: nil)
91
+ reserved = false
92
+ observed = false
93
+ @mutex.synchronize do
94
+ if @finished || @workflow_done || (check_approval && !@approvals.include?(check_approval))
95
+ raise Phronomy::Error.new("Generation invocation is no longer accepting operations",
96
+ code: "generation.inactive")
97
+ end
98
+ @pending += 1
99
+ reserved = true
100
+ end
101
+ task = yield
102
+ unless task.is_a?(Phronomy::TaskResult)
103
+ raise TypeError, "Agent operation must return TaskResult"
104
+ end
105
+ task.on_complete { |_value, _error| child_finished }
106
+ observed = true
107
+ task
108
+ ensure
109
+ child_finished if reserved && !observed
110
+ end
111
+
112
+ def child_finished
113
+ @mutex.synchronize { @pending -= 1 }
114
+ try_finish
115
+ end
116
+
117
+ def finish_workflow(value, error)
118
+ @mutex.synchronize do
119
+ @workflow_done = true
120
+ @value, @error = value, error
121
+ end
122
+ cancel if error
123
+ try_finish
124
+ end
125
+
126
+ def try_finish
127
+ completion = @mutex.synchronize do
128
+ next if @finished || !@workflow_done || !@pending.zero?
129
+ @finished = true
130
+ [@value, @primary_error || @error]
131
+ end
132
+ return unless completion
133
+
134
+ @subscriptions.close
135
+ @on_finish.call
136
+ value, error = completion
137
+ if error.is_a?(Phronomy::CancellationError)
138
+ @result.cancel!(error)
139
+ elsif error
140
+ @result.fail(error)
141
+ else
142
+ @result.complete(value)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -16,6 +16,7 @@ module Phronomy
16
16
  field :output, type: :replace, default: -> { {} }
17
17
  field :draft_request_id, type: :replace, default: nil
18
18
  field :review_request_id, type: :replace, default: nil
19
+ field :interrupted_phase, type: :replace, default: nil
19
20
  field :pipeline_error, type: :replace, default: nil
20
21
 
21
22
  # Application-level event interpretation. Correlation IDs belong to this
@@ -39,6 +40,21 @@ module Phronomy
39
40
  self.review_notes = review_notes + [event.payload[:feedback]]
40
41
  self.review_request_id = nil
41
42
  self.pipeline_error = nil
43
+ when :draft_rejected
44
+ return :consume unless event.payload[:request_id] == draft_request_id
45
+ self.interrupted_phase = :draft
46
+ self.draft_request_id = nil
47
+ when :review_rejected
48
+ return :consume unless event.payload[:request_id] == review_request_id
49
+ self.interrupted_phase = :review
50
+ self.review_request_id = nil
51
+ when :draft_output_failed, :review_output_failed
52
+ phase = (event.type == :draft_output_failed) ? :draft : :review
53
+ expected = (phase == :draft) ? draft_request_id : review_request_id
54
+ return :consume unless event.payload[:request_id] == expected
55
+ self.pipeline_error = event.payload.fetch(:error)
56
+ self.interrupted_phase = phase
57
+ (phase == :draft) ? self.draft_request_id = nil : self.review_request_id = nil
42
58
  when :draft_failed
43
59
  return :consume unless event.payload[:request_id] == draft_request_id
44
60
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class GeneratorVerifier
5
+ # Generation defines the information it needs. Agent owns schema validation.
6
+ # @api private
7
+ module Protocol
8
+ DEFAULT_CITATION = Phronomy::Values::Immutable.json({
9
+ type: "object", properties: {source: {type: "string"}, excerpt: {type: "string"}},
10
+ required: %w[source excerpt], additionalProperties: false
11
+ })
12
+ REVIEW = Phronomy::Values::Immutable.json({
13
+ type: "object", properties: {approved: {type: "boolean"}, score: {type: "number", minimum: 0, maximum: 1},
14
+ feedback: {type: "string"}},
15
+ required: %w[approved score feedback], additionalProperties: false
16
+ })
17
+
18
+ module_function
19
+
20
+ def draft(citation_schema)
21
+ citation = Phronomy::Values::Immutable.json(citation_schema)
22
+ unless citation.is_a?(Hash) && citation["type"] == "object"
23
+ raise ArgumentError, "citation_schema must declare an object"
24
+ end
25
+ Phronomy::Values::Immutable.json({
26
+ type: "object", properties: {answer: {type: "string"}, confidence: {type: "number", minimum: 0, maximum: 1},
27
+ citations: {type: "array", items: embed_citation(citation)}},
28
+ required: %w[answer confidence citations], additionalProperties: false
29
+ })
30
+ end
31
+
32
+ # A local citation reference is relative to the citation document. Rebase
33
+ # only schema positions when embedding it, leaving const/enum data intact.
34
+ def embed_citation(node)
35
+ return node unless node.is_a?(Hash)
36
+ node.to_h do |key, value|
37
+ transformed = case key
38
+ when "$ref"
39
+ (value.is_a?(String) && value.start_with?("#")) ? "#/properties/citations/items#{value.delete_prefix("#")}" : value
40
+ when "properties", "$defs"
41
+ value.is_a?(Hash) ? value.transform_values { |child| embed_citation(child) } : value
42
+ when "items", "additionalProperties"
43
+ embed_citation(value)
44
+ when "anyOf"
45
+ value.is_a?(Array) ? value.map { |child| embed_citation(child) } : value
46
+ else
47
+ value
48
+ end
49
+ [key, transformed]
50
+ end
51
+ end
52
+ private_class_method :embed_citation
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class GeneratorVerifier
5
+ # Immutable, JSON-reconstructible facts about one generation attempt.
6
+ # Acceptance is the configured evaluation outcome, not application publication.
7
+ class Result
8
+ FIELDS = %i[output confidence citations iterations review_notes
9
+ confidence_threshold review_approved termination_reason interrupted_phase failure].freeze
10
+ attr_reader(*FIELDS)
11
+
12
+ def self.accepted?(confidence:, confidence_threshold:, review_approved:)
13
+ review_approved == true && !confidence.nil? && confidence >= confidence_threshold
14
+ end
15
+
16
+ def self.from_h(hash)
17
+ raise ArgumentError, "Result data must be a Hash" unless hash.is_a?(Hash)
18
+ fields = hash.each_with_object({}) do |(key, value), result|
19
+ unless key.is_a?(String) || key.is_a?(Symbol)
20
+ raise ArgumentError, "Result keys must be String or Symbol"
21
+ end
22
+ key = key.to_sym
23
+ raise ArgumentError, "duplicate Result field: #{key}" if result.key?(key)
24
+ result[key] = value
25
+ end
26
+ raise ArgumentError, "unknown Result fields" unless (fields.keys - FIELDS).empty?
27
+ raise ArgumentError, "missing Result fields" unless (FIELDS - fields.keys).empty?
28
+ new(**fields)
29
+ end
30
+
31
+ def initialize(output:, confidence:, citations:, iterations:, review_notes:,
32
+ confidence_threshold:, review_approved:, termination_reason:, interrupted_phase: nil, failure: nil)
33
+ @output = output&.dup&.freeze
34
+ @confidence = score(confidence, optional: true)
35
+ @confidence_threshold = score(confidence_threshold)
36
+ @review_approved = review_approved
37
+ @termination_reason = termination_reason.to_s.freeze
38
+ @interrupted_phase = interrupted_phase&.to_s&.freeze
39
+ @failure = Phronomy::Values::Immutable.json(failure)
40
+ @iterations = iterations
41
+ @citations = Phronomy::Values::Immutable.json(citations)
42
+ @review_notes = Phronomy::Values::Immutable.copy(review_notes)
43
+ validate!
44
+ freeze
45
+ end
46
+
47
+ def accepted?
48
+ @termination_reason == "accepted"
49
+ end
50
+
51
+ def to_h
52
+ FIELDS.to_h { |field| [field, public_send(field)] }
53
+ end
54
+
55
+ def ==(other)
56
+ other.instance_of?(self.class) && to_h == other.to_h
57
+ end
58
+ alias_method :eql?, :==
59
+
60
+ def hash
61
+ [self.class, to_h].hash
62
+ end
63
+
64
+ private
65
+
66
+ def score(value, optional: false)
67
+ return nil if optional && value.nil?
68
+ unless value.is_a?(Numeric) && value.real? && value.to_f.finite? && (0.0..1.0).cover?(value)
69
+ raise ArgumentError, "scores and threshold must be finite numbers in 0..1"
70
+ end
71
+ value.to_f
72
+ end
73
+
74
+ def validate!
75
+ unless @output.nil? || @output.is_a?(String)
76
+ raise ArgumentError, "output must be a String or nil"
77
+ end
78
+ unless @iterations.is_a?(Integer) && @iterations >= 0
79
+ raise ArgumentError, "iterations must be a nonnegative Integer"
80
+ end
81
+ unless @citations.is_a?(Array) && @citations.all?(Hash) &&
82
+ @review_notes.is_a?(Array) && @review_notes.all?(String)
83
+ raise ArgumentError, "citations must be hashes and review_notes must be strings"
84
+ end
85
+ if @termination_reason == "output_failed"
86
+ unless @failure.is_a?(Hash) && %w[agent.output_invalid agent.output_refused agent.output_incomplete].include?(@failure["code"]) &&
87
+ %w[reason execution_id schema_digest].all? { |key| @failure[key].is_a?(String) && !@failure[key].empty? } &&
88
+ @failure["violations"].is_a?(Array) && @failure["class"] == "Phronomy::Agent::OutputError"
89
+ raise ArgumentError, "output failure requires a complete Agent output diagnostic"
90
+ end
91
+ reasons = {"agent.output_invalid" => %w[invalid_json schema_mismatch missing_output],
92
+ "agent.output_refused" => %w[model_refusal],
93
+ "agent.output_incomplete" => %w[length content_filter unknown_finish_reason]}
94
+ unless reasons.fetch(@failure.fetch("code")).include?(@failure.fetch("reason"))
95
+ raise ArgumentError, "output failure reason contradicts its code"
96
+ end
97
+ if @interrupted_phase == "draft"
98
+ raise ArgumentError, "failed draft cannot carry an earlier answer" unless @output.nil? && @citations.empty?
99
+ elsif @interrupted_phase == "review"
100
+ raise ArgumentError, "failed review requires a valid draft" unless @output.is_a?(String) && @iterations.positive?
101
+ end
102
+ elsif @failure
103
+ raise ArgumentError, "only output_failed may carry failure"
104
+ end
105
+ if %w[tool_rejected output_failed].include?(@termination_reason)
106
+ unless %w[draft review].include?(@interrupted_phase) && @review_approved.nil? && @confidence.nil?
107
+ raise ArgumentError, "interruption must identify its phase and has no completed evaluation"
108
+ end
109
+ return
110
+ end
111
+ unless @iterations.positive? && @output.is_a?(String) && !@confidence.nil? &&
112
+ [true, false].include?(@review_approved) && @interrupted_phase.nil?
113
+ raise ArgumentError, "completed evaluation requires output, score and boolean review_approved"
114
+ end
115
+ expected = self.class.accepted?(confidence: @confidence,
116
+ confidence_threshold: @confidence_threshold, review_approved: @review_approved) ? "accepted" : "iteration_limit"
117
+ raise ArgumentError, "termination_reason contradicts evaluation" unless @termination_reason == expected
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class GeneratorVerifier
5
+ class UnacceptedResultError < Phronomy::Error
6
+ attr_reader :result
7
+
8
+ def initialize(result, code: "generation.result_unaccepted")
9
+ @result = result
10
+ super("Generation did not satisfy its evaluation conditions (#{result.termination_reason})", code: code)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -9,19 +9,16 @@ module Phronomy
9
9
  def initialize(
10
10
  draft_agent:, review_agent:,
11
11
  draft_prompt_builder:, review_prompt_builder:,
12
- draft_result_parser:, review_result_parser:,
13
- threshold:, max_iterations:
12
+ threshold:, max_iterations:, start_operation:
14
13
  )
14
+ @start_operation = start_operation
15
15
  @draft_agent = draft_agent
16
16
  @review_agent = review_agent
17
17
  @draft_prompt_builder = draft_prompt_builder
18
18
  @review_prompt_builder = review_prompt_builder
19
19
  @threshold = threshold
20
20
  @max_iterations = max_iterations
21
- @result_receiver = AgentResultReceiver.new(
22
- draft_result_parser: draft_result_parser,
23
- review_result_parser: review_result_parser
24
- )
21
+ @result_receiver = AgentResultReceiver.new
25
22
  end
26
23
 
27
24
  def build
@@ -48,6 +45,10 @@ module Phronomy
48
45
 
49
46
  transition from: :draft, on: :draft_completed, to: :review
50
47
  transition from: :draft, on: :draft_failed, to: :failed
48
+ transition from: :draft, on: :draft_output_failed, to: :finalize
49
+ transition from: :review, on: :review_output_failed, to: :finalize
50
+ transition from: :draft, on: :draft_rejected, to: :finalize
51
+ transition from: :review, on: :review_rejected, to: :finalize
51
52
  transition from: :review,
52
53
  on: :review_completed,
53
54
  guard: ready_to_finalize,
@@ -71,7 +72,7 @@ module Phronomy
71
72
  workflow_instance_id: next_state.workflow_instance_id,
72
73
  request_id: request_id
73
74
  )
74
- @draft_agent.start(prompt, listener: listener)
75
+ @start_operation.call(@draft_agent, :draft, prompt, listener: listener)
75
76
  next_state
76
77
  end
77
78
 
@@ -86,18 +87,19 @@ module Phronomy
86
87
  workflow_instance_id: next_state.workflow_instance_id,
87
88
  request_id: request_id
88
89
  )
89
- @review_agent.start(prompt, listener: listener)
90
+ @start_operation.call(@review_agent, :review, prompt, listener: listener)
90
91
  next_state
91
92
  end
92
93
 
93
94
  def ready_to_finalize?(state)
94
- confidence = [state.self_score || 0.0, state.review_score || 0.0].min
95
- (confidence >= @threshold && state.approved) ||
95
+ confidence = [state.self_score, state.review_score].min
96
+ Result.accepted?(confidence: confidence, confidence_threshold: @threshold, review_approved: state.approved) ||
96
97
  state.iteration >= @max_iterations
97
98
  end
98
99
 
99
100
  def finalize(state)
100
- state.output = state.draft
101
+ state.output = (state.interrupted_phase == :draft) ? nil : state.draft
102
+ state.citations = [] if state.interrupted_phase == :draft
101
103
  state
102
104
  end
103
105