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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -1
- data/README.md +32 -6
- data/docs/application-recipes.md +229 -0
- data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
- data/docs/architecture/agent-iteration-budget.md +81 -0
- data/docs/architecture/dx05-agent-scope.md +126 -0
- data/docs/architecture/dx05-workflow-task-state.md +134 -0
- data/docs/architecture/error-api-review.md +245 -0
- data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
- data/docs/architecture/generator-verifier-ownership.md +18 -109
- data/docs/architecture/llm-continuation-metadata.md +88 -0
- data/docs/architecture/lm-studio-output-failures.md +111 -0
- data/docs/architecture/r8-unit15.md +3 -0
- data/docs/architecture/r8-unit7.md +2 -1
- data/docs/architecture/structured-output-and-scope.md +183 -0
- data/docs/architecture/tracing.md +22 -4
- data/docs/architecture/transport-error-boundaries.md +103 -0
- data/docs/architecture/trust-acceptance-followup.md +87 -0
- data/docs/failure-reason-codes.md +45 -0
- data/docs/features.md +14 -2
- data/docs/getting-started.md +44 -43
- data/docs/migrations/r8-unit15.md +3 -0
- data/docs/ordered-event-delivery.md +54 -0
- data/docs/workflow-completion.md +92 -0
- data/gemfiles/http_minimum.gemfile +10 -0
- data/lib/phronomy/agent/admission.rb +1 -1
- data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
- data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
- data/lib/phronomy/agent/api/agent.rb +13 -0
- data/lib/phronomy/agent/async_event_api.rb +121 -88
- data/lib/phronomy/agent/base.rb +82 -44
- data/lib/phronomy/agent/before_llm_input.rb +7 -1
- data/lib/phronomy/agent/context_importer.rb +4 -0
- data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
- data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
- data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
- data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
- data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
- data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
- data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
- data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
- data/lib/phronomy/agent/execution_observation.rb +5 -5
- data/lib/phronomy/agent/filterable.rb +21 -15
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
- data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
- data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
- data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
- data/lib/phronomy/agent/output_contract.rb +64 -0
- data/lib/phronomy/agent/output_error.rb +53 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
- data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
- data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
- data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
- data/lib/phronomy/agent/runtime_input.rb +15 -4
- data/lib/phronomy/agent/saved_context_reader.rb +2 -1
- data/lib/phronomy/agent/store.rb +10 -2
- data/lib/phronomy/agent/stream_callback_error.rb +2 -2
- data/lib/phronomy/common/error.rb +65 -1
- data/lib/phronomy/common/values/immutable.rb +33 -0
- data/lib/phronomy/content_store/base.rb +7 -2
- data/lib/phronomy/context/assembly.rb +18 -22
- data/lib/phronomy/embeddings/authentication_error.rb +11 -0
- data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
- data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
- data/lib/phronomy/engine/fsm_session.rb +16 -1
- data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
- data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
- data/lib/phronomy/execution/task_result.rb +7 -2
- data/lib/phronomy/filter/filter_block_error.rb +2 -2
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
- data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
- data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
- data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
- data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
- data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
- data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
- data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
- data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
- data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
- data/lib/phronomy/generation/generator_verifier.rb +170 -85
- data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
- data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
- data/lib/phronomy/llm_adapter/message.rb +4 -0
- data/lib/phronomy/llm_adapter/request.rb +17 -2
- data/lib/phronomy/llm_adapter/response.rb +11 -4
- data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
- data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
- data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
- data/lib/phronomy/multi_agent/shared_state.rb +1 -0
- data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
- data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
- data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
- data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
- data/lib/phronomy/output_parser/parse_error.rb +10 -1
- data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
- data/lib/phronomy/storage/condition_failed_error.rb +2 -2
- data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
- data/lib/phronomy/tool/base.rb +25 -23
- data/lib/phronomy/tracing/automatic.rb +14 -8
- data/lib/phronomy/tracing/base.rb +37 -7
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/tracing/observation.rb +2 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +30 -2
- data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
- data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
- data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
- data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
- data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
- data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
- data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
- data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
- data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
- data/lib/phronomy.rb +7 -2
- data/scripts/api_snapshot.rb +15 -0
- data/scripts/check_readme_ruby.rb +24 -29
- data/scripts/check_readme_runnable.rb +92 -81
- data/scripts/migrate_generator_verifier_result.rb +18 -0
- data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
- data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
- data/sig/phronomy/agent/execution_contract.rbs +4 -3
- data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
- data/sig/phronomy/agent.rbs +36 -5
- data/sig/phronomy/embeddings/base.rbs +6 -0
- data/sig/phronomy/execution/execution.rbs +2 -2
- data/sig/phronomy/extensions.rbs +8 -2
- data/sig/phronomy/filter/filter_block_error.rbs +6 -0
- data/sig/phronomy/generation/generator_verifier.rbs +70 -0
- data/sig/phronomy/integrations.rbs +14 -0
- data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
- data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
- data/sig/phronomy/llm_adapter/values.rbs +8 -2
- data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
- data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
- data/sig/phronomy/storage/contracts.rbs +2 -2
- data/sig/phronomy/tracing/observation.rbs +1 -1
- data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
- data/sig/phronomy/workflow.rbs +4 -0
- data/sig/phronomy.rbs +6 -0
- metadata +103 -5
- data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
- data/lib/phronomy/generation/low_confidence_error.rb +0 -14
- 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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
|
95
|
-
(confidence
|
|
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
|
-
#
|
|
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
|
-
|
|
13
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
46
|
-
@
|
|
47
|
-
@
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
(
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
78
|
-
|
|
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
|
|
82
|
-
WorkflowBuilder.new(
|
|
83
|
-
draft_agent:
|
|
84
|
-
review_agent:
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
115
|
-
|
|
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
|