phronomy 0.29.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 +17 -6
- 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 +9 -2
- data/docs/architecture/error-api-review.md +4 -4
- 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/features.md +10 -1
- data/docs/migrations/r8-unit15.md +3 -0
- data/gemfiles/http_minimum.gemfile +6 -1
- data/lib/phronomy/agent/admission.rb +1 -1
- data/lib/phronomy/agent/async_event_api.rb +16 -7
- data/lib/phronomy/agent/base.rb +8 -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 +9 -0
- data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
- data/lib/phronomy/agent/execution/execution_coordinator.rb +63 -5
- 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 +10 -1
- 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 +3 -0
- data/lib/phronomy/agent/execution_observation.rb +5 -5
- data/lib/phronomy/agent/lifecycle/scope.rb +33 -23
- data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +4 -2
- 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 +3 -3
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator.rb +1 -1
- data/lib/phronomy/agent/recovery/recovery_support.rb +5 -1
- 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/common/values/immutable.rb +33 -0
- data/lib/phronomy/context/assembly.rb +7 -4
- data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +3 -1
- data/lib/phronomy/engine/fsm_session.rb +16 -1
- 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/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/shared_state.rb +1 -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/tracing/automatic.rb +14 -8
- data/lib/phronomy/tracing/base.rb +2 -1
- 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 +3 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +1 -2
- data/scripts/api_snapshot.rb +3 -0
- data/scripts/migrate_generator_verifier_result.rb +18 -0
- data/sig/phronomy/agent/execution_contract.rbs +4 -3
- data/sig/phronomy/agent.rbs +20 -7
- data/sig/phronomy/extensions.rbs +8 -3
- data/sig/phronomy/generation/generator_verifier.rbs +70 -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/tracing/observation.rbs +1 -1
- data/sig/phronomy.rbs +2 -0
- metadata +59 -8
- 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
- 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 =
|
|
23
|
+
parsed = output
|
|
29
24
|
{
|
|
30
|
-
draft: parsed
|
|
31
|
-
self_score:
|
|
32
|
-
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 =
|
|
32
|
+
parsed = output
|
|
38
33
|
{
|
|
39
|
-
review_score:
|
|
40
|
-
approved: parsed
|
|
41
|
-
feedback: parsed
|
|
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
|
-
|
|
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
|
|