phronomy 0.28.0 → 0.30.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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -1
  3. data/README.md +32 -6
  4. data/docs/application-recipes.md +229 -0
  5. data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
  6. data/docs/architecture/agent-iteration-budget.md +81 -0
  7. data/docs/architecture/dx05-agent-scope.md +126 -0
  8. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  9. data/docs/architecture/error-api-review.md +245 -0
  10. data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
  11. data/docs/architecture/generator-verifier-ownership.md +18 -109
  12. data/docs/architecture/llm-continuation-metadata.md +88 -0
  13. data/docs/architecture/lm-studio-output-failures.md +111 -0
  14. data/docs/architecture/r8-unit15.md +3 -0
  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/failure-reason-codes.md +45 -0
  21. data/docs/features.md +14 -2
  22. data/docs/getting-started.md +44 -43
  23. data/docs/migrations/r8-unit15.md +3 -0
  24. data/docs/ordered-event-delivery.md +54 -0
  25. data/docs/workflow-completion.md +92 -0
  26. data/gemfiles/http_minimum.gemfile +10 -0
  27. data/lib/phronomy/agent/admission.rb +1 -1
  28. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  29. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  30. data/lib/phronomy/agent/api/agent.rb +13 -0
  31. data/lib/phronomy/agent/async_event_api.rb +121 -88
  32. data/lib/phronomy/agent/base.rb +82 -44
  33. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  34. data/lib/phronomy/agent/context_importer.rb +4 -0
  35. data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
  36. data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
  37. data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
  38. data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
  39. data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
  40. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
  41. data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
  42. data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
  43. data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
  44. data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
  45. data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
  46. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
  47. data/lib/phronomy/agent/execution_observation.rb +5 -5
  48. data/lib/phronomy/agent/filterable.rb +21 -15
  49. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  50. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  51. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  52. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  53. data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
  54. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
  55. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  56. data/lib/phronomy/agent/output_contract.rb +64 -0
  57. data/lib/phronomy/agent/output_error.rb +53 -0
  58. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
  59. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
  60. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  61. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
  62. data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
  63. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
  64. data/lib/phronomy/agent/runtime_input.rb +15 -4
  65. data/lib/phronomy/agent/saved_context_reader.rb +2 -1
  66. data/lib/phronomy/agent/store.rb +10 -2
  67. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  68. data/lib/phronomy/common/error.rb +65 -1
  69. data/lib/phronomy/common/values/immutable.rb +33 -0
  70. data/lib/phronomy/content_store/base.rb +7 -2
  71. data/lib/phronomy/context/assembly.rb +18 -22
  72. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  73. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
  74. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  75. data/lib/phronomy/engine/fsm_session.rb +16 -1
  76. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  77. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  78. data/lib/phronomy/execution/task_result.rb +7 -2
  79. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  80. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
  81. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
  82. data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
  83. data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
  84. data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
  85. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
  86. data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
  87. data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
  88. data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
  89. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
  90. data/lib/phronomy/generation/generator_verifier.rb +170 -85
  91. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  92. data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
  93. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
  94. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
  95. data/lib/phronomy/llm_adapter/message.rb +4 -0
  96. data/lib/phronomy/llm_adapter/request.rb +17 -2
  97. data/lib/phronomy/llm_adapter/response.rb +11 -4
  98. data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
  99. data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
  100. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  101. data/lib/phronomy/multi_agent/shared_state.rb +1 -0
  102. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  103. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  104. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  105. data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
  106. data/lib/phronomy/output_parser/parse_error.rb +10 -1
  107. data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
  108. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  109. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  110. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  111. data/lib/phronomy/tool/base.rb +25 -23
  112. data/lib/phronomy/tracing/automatic.rb +14 -8
  113. data/lib/phronomy/tracing/base.rb +37 -7
  114. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  115. data/lib/phronomy/tracing/observation.rb +2 -1
  116. data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
  117. data/lib/phronomy/version.rb +1 -1
  118. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  119. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  120. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  121. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  122. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  123. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  124. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  125. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  126. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  127. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  128. data/lib/phronomy.rb +7 -2
  129. data/scripts/api_snapshot.rb +15 -0
  130. data/scripts/check_readme_ruby.rb +24 -29
  131. data/scripts/check_readme_runnable.rb +92 -81
  132. data/scripts/migrate_generator_verifier_result.rb +18 -0
  133. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  134. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  135. data/sig/phronomy/agent/execution_contract.rbs +4 -3
  136. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  137. data/sig/phronomy/agent.rbs +36 -5
  138. data/sig/phronomy/embeddings/base.rbs +6 -0
  139. data/sig/phronomy/execution/execution.rbs +2 -2
  140. data/sig/phronomy/extensions.rbs +8 -2
  141. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  142. data/sig/phronomy/generation/generator_verifier.rbs +70 -0
  143. data/sig/phronomy/integrations.rbs +14 -0
  144. data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
  145. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
  146. data/sig/phronomy/llm_adapter/values.rbs +8 -2
  147. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  148. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  149. data/sig/phronomy/storage/contracts.rbs +2 -2
  150. data/sig/phronomy/tracing/observation.rbs +1 -1
  151. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  152. data/sig/phronomy/workflow.rbs +4 -0
  153. data/sig/phronomy.rbs +6 -0
  154. metadata +103 -5
  155. data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
  156. data/lib/phronomy/generation/low_confidence_error.rb +0 -14
  157. data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
@@ -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
 
@@ -1,118 +1,203 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Implements the Generator-Verifier multi-agent coordination pattern.
5
- #
6
- # Agent completion is integrated through application-defined Workflow events;
7
- # Workflow entry actions do not return or await Agent Tasks.
4
+ # Application-facing draft/review/retry composition.
5
+ # Owns its Agent instances; Tool authorization remains an application decision.
8
6
  class GeneratorVerifier
9
7
  DEFAULT_CONFIDENCE_THRESHOLD = 0.7
10
8
  DEFAULT_MAX_ITERATIONS = 3
11
9
 
12
- Result = Struct.new(
13
- :output,
14
- :confidence,
15
- :citations,
16
- :iterations,
17
- :review_notes,
18
- :trusted
19
- ) do
20
- alias_method :trusted?, :trusted
21
- end
10
+ private_constant :PipelineState, :WorkflowBuilder, :AgentResultReceiver,
11
+ :AgentOperation, :Invocation, :Protocol
22
12
 
23
- private_constant :PipelineState, :WorkflowBuilder, :AgentResultReceiver, :AgentOperation
13
+ def self.open(**options)
14
+ raise ArgumentError, "GeneratorVerifier.open requires a block" unless block_given?
15
+ pipeline = new(**options)
16
+ value = nil
17
+ primary = nil
18
+ begin
19
+ returned = yield pipeline
20
+ value = returned.is_a?(Phronomy::TaskResult) ? returned.wait_result : returned
21
+ rescue Exception => error # rubocop:disable Lint/RescueException -- retain Interrupt during cleanup
22
+ primary = error
23
+ raise
24
+ ensure
25
+ begin
26
+ pipeline.release!
27
+ rescue => cleanup
28
+ failures = cleanup.is_a?(CleanupError) ? cleanup.failures : [{phase: nil, error: cleanup}]
29
+ raise CleanupError.new(pipeline: pipeline, failures: failures,
30
+ result: value, primary_error: primary), cause: primary || failures.first.fetch(:error)
31
+ end
32
+ end
33
+ value
34
+ end
24
35
 
25
- def initialize(
26
- draft_agent:,
27
- review_agent:,
28
- draft_prompt_builder:,
29
- review_prompt_builder:,
30
- draft_result_parser: nil,
31
- review_result_parser: nil,
36
+ def initialize(draft_agent:, review_agent:, draft_prompt_builder:, review_prompt_builder:,
37
+ citation_schema: Protocol::DEFAULT_CITATION,
32
38
  confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
33
- max_iterations: DEFAULT_MAX_ITERATIONS,
34
- raise_if_untrusted: false
35
- )
36
- @draft_agent_class = draft_agent
37
- @review_agent_class = review_agent
38
- @draft_prompt_builder = draft_prompt_builder
39
- @review_prompt_builder = review_prompt_builder
40
- @draft_result_parser =
41
- draft_result_parser || method(:default_parse_draft)
42
- @review_result_parser =
43
- review_result_parser || method(:default_parse_review)
39
+ max_iterations: DEFAULT_MAX_ITERATIONS, raise_if_unaccepted: false, on_approval: nil)
40
+ ensure_blocking_allowed!(:new)
41
+ unless confidence_threshold.is_a?(Numeric) && confidence_threshold.real? && confidence_threshold.to_f.finite? &&
42
+ (0.0..1.0).cover?(confidence_threshold)
43
+ raise ArgumentError, "confidence_threshold must be a finite number in 0..1"
44
+ end
45
+ unless max_iterations.is_a?(Integer) && max_iterations.positive?
46
+ raise ArgumentError, "max_iterations must be a positive Integer"
47
+ end
48
+ [draft_prompt_builder, review_prompt_builder, on_approval].compact.each do |callable|
49
+ raise ArgumentError, "builders and on_approval must be callable" unless callable.respond_to?(:call)
50
+ end
51
+ unless raise_if_unaccepted == true || raise_if_unaccepted == false
52
+ raise ArgumentError, "raise_if_unaccepted must be true or false"
53
+ end
54
+ @mutex = Mutex.new
55
+ @status = :idle
56
+ @operations = {}
57
+ @active = nil
44
58
  @threshold = confidence_threshold.to_f
45
- @max_iterations = max_iterations.to_i
46
- @raise_if_untrusted = raise_if_untrusted
47
- @compiled_workflow = nil
59
+ @max_iterations = max_iterations
60
+ @raise_if_unaccepted = raise_if_unaccepted
61
+ @on_approval = on_approval
62
+ @draft_prompt_builder, @review_prompt_builder = draft_prompt_builder, review_prompt_builder
63
+ draft_schema = Protocol.draft(citation_schema)
64
+ begin
65
+ @operations[:draft] = AgentOperation.new(draft_agent, output_schema: draft_schema)
66
+ @operations[:review] = AgentOperation.new(review_agent, output_schema: Protocol::REVIEW)
67
+ rescue Exception => primary # rubocop:disable Lint/RescueException -- release partially acquired resources
68
+ begin
69
+ release!
70
+ rescue CleanupError => cleanup
71
+ raise CleanupError.new(pipeline: self, failures: cleanup.failures,
72
+ primary_error: primary), cause: primary
73
+ end
74
+ raise
75
+ end
48
76
  end
49
77
 
50
78
  def invoke(input, config: {})
51
- state = compiled_workflow.invoke({input: input}, config: config)
52
- confidence = combined_confidence(state)
53
- trusted = confidence >= @threshold
54
- result = Result.new(
55
- output: state.output || state.draft.to_s,
56
- confidence: confidence,
57
- citations: state.citations,
58
- iterations: state.iteration,
59
- review_notes: state.review_notes,
60
- trusted: trusted
61
- )
62
- if @raise_if_untrusted && !trusted
63
- raise LowConfidenceError.new(result)
79
+ ensure_blocking_allowed!(:invoke)
80
+ invoke_async(input, config: config).wait_result
81
+ end
82
+
83
+ def invoke_async(input, config: {})
84
+ invocation = @mutex.synchronize do
85
+ assert_idle!
86
+ current = Invocation.new(config: config, on_approval: @on_approval,
87
+ on_finish: -> { @mutex.synchronize { @active = nil } })
88
+ @active = current
64
89
  end
65
- result
90
+ invocation.follow(compiled_workflow.invoke_async({input: input}, config: invocation.config)) do |state|
91
+ build_result(state)
92
+ end
93
+ rescue => error
94
+ invocation ? invocation.fail_start(error) : Phronomy::TaskResult.failed(error)
95
+ end
96
+
97
+ # Notifications issued in the current invocation, including already-answered
98
+ # requests. This is not a replica of the Agent's durable approval state.
99
+ def approval_requests
100
+ @mutex.synchronize { @active }&.approvals || [].freeze
101
+ end
102
+
103
+ # Requests cancellation; this Task does not promise physical quiescence.
104
+ # Await the invocation separately, then use release! to check safe release.
105
+ def cancel_async
106
+ current, operations = @mutex.synchronize do
107
+ if @status == :releasing
108
+ raise Phronomy::Error.new("Generation is releasing", code: "generation.busy")
109
+ end
110
+ [@active, @operations.values.dup]
111
+ end
112
+ current&.cancel
113
+ tasks = operations.filter_map(&:cancel_async)
114
+ Phronomy::TaskResult.all_settled(tasks).map do |outcomes|
115
+ failure = outcomes.find(&:error)
116
+ raise failure.error if failure
117
+ !!current || !tasks.empty?
118
+ end
119
+ rescue => error
120
+ Phronomy::TaskResult.failed(error)
121
+ end
122
+
123
+ def release!
124
+ ensure_blocking_allowed!(:release!)
125
+ operations = @mutex.synchronize do
126
+ return false if @status == :released
127
+ if @active || @status == :releasing
128
+ raise Phronomy::Error.new("Generation is busy", code: "generation.busy")
129
+ end
130
+ @status = :releasing
131
+ @operations.dup
132
+ end
133
+ failures = []
134
+ operations.each do |phase, operation|
135
+ operation.unload!
136
+ @mutex.synchronize { @operations.delete(phase) }
137
+ rescue => error
138
+ failures << {phase: phase, error: error}
139
+ end
140
+ @mutex.synchronize { @status = failures.empty? ? :released : :release_failed }
141
+ unless failures.empty?
142
+ raise CleanupError.new(pipeline: self, failures: failures), cause: failures.first.fetch(:error)
143
+ end
144
+ @compiled_workflow = nil
145
+ true
66
146
  end
67
147
 
68
148
  private
69
149
 
70
- def combined_confidence(state)
71
- [
72
- (state.self_score || 0.0).to_f,
73
- (state.review_score || 0.0).to_f
74
- ].min
150
+ def assert_idle!
151
+ if @status != :idle
152
+ raise Phronomy::Error.new("Generation is #{@status}", code: "generation.released")
153
+ end
154
+ raise Phronomy::Error.new("Generation is busy", code: "generation.busy") if @active
75
155
  end
76
156
 
77
- def compiled_workflow
78
- @compiled_workflow ||= build_workflow
157
+ def ensure_blocking_allowed!(operation)
158
+ if Phronomy::WaitPolicy.blocking_forbidden?
159
+ raise Phronomy::EventLoopReentrancyError, "GeneratorVerifier##{operation} cannot run on EventLoop"
160
+ end
79
161
  end
80
162
 
81
- def build_workflow
82
- WorkflowBuilder.new(
83
- draft_agent: AgentOperation.new(@draft_agent_class),
84
- review_agent: AgentOperation.new(@review_agent_class),
163
+ def compiled_workflow
164
+ @compiled_workflow ||= WorkflowBuilder.new(
165
+ draft_agent: @operations.fetch(:draft),
166
+ review_agent: @operations.fetch(:review),
85
167
  draft_prompt_builder: @draft_prompt_builder,
86
168
  review_prompt_builder: @review_prompt_builder,
87
- draft_result_parser: @draft_result_parser,
88
- review_result_parser: @review_result_parser,
89
- threshold: @threshold,
90
- max_iterations: @max_iterations
169
+ threshold: @threshold, max_iterations: @max_iterations,
170
+ start_operation: ->(operation, phase, input, listener:) {
171
+ @mutex.synchronize { @active }.start(operation, phase, input, listener: listener)
172
+ }
91
173
  ).build
92
174
  end
93
175
 
94
- def default_parse_draft(text)
95
- default_parser.parse(text)
96
- rescue Phronomy::ParseError
97
- {
98
- answer: text.to_s,
99
- confidence: 0.0,
100
- citations: []
101
- }
102
- end
103
-
104
- def default_parse_review(text)
105
- default_parser.parse(text)
106
- rescue Phronomy::ParseError
107
- {
108
- approved: false,
109
- score: 0.0,
110
- feedback: "Review output could not be parsed: #{text}"
111
- }
176
+ def build_result(state)
177
+ interrupted = state.interrupted_phase
178
+ confidence = interrupted ? nil : [state.self_score, state.review_score].min
179
+ approved = interrupted ? nil : state.approved
180
+ accepted = Result.accepted?(confidence: confidence, confidence_threshold: @threshold, review_approved: approved)
181
+ result = Result.new(output: state.output, confidence: confidence,
182
+ confidence_threshold: @threshold, review_approved: approved,
183
+ citations: state.citations, iterations: state.iteration, review_notes: state.review_notes,
184
+ termination_reason: if interrupted
185
+ state.pipeline_error ? "output_failed" : "tool_rejected"
186
+ else
187
+ (accepted ? "accepted" : "iteration_limit")
188
+ end,
189
+ interrupted_phase: interrupted,
190
+ failure: output_failure_diagnostic(state.pipeline_error))
191
+ if @raise_if_unaccepted && !result.accepted?
192
+ raise UnacceptedResultError.new(result), cause: state.pipeline_error
193
+ end
194
+ result
112
195
  end
113
196
 
114
- def default_parser
115
- @default_parser ||= DefaultParser.build
197
+ def output_failure_diagnostic(error)
198
+ return nil unless error
199
+ Phronomy::Error.diagnostic(error).merge("execution_id" => error.execution_id,
200
+ "schema_digest" => error.schema_digest, "reason" => error.reason, "violations" => error.violations)
116
201
  end
117
202
  end
118
203
  end