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
|
@@ -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
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module LLMAdapter
|
|
7
|
+
# Explicit LM Studio / RubyLLM connection. Does not alter the default backend.
|
|
8
|
+
# Identifies a narrowly specified provider response, not exception messages.
|
|
9
|
+
# @api public
|
|
10
|
+
class LMStudio < RubyLLM
|
|
11
|
+
PARSE_FAILURE_CODE = "llm.provider_output_parse_failed"
|
|
12
|
+
ENGINE_PREFIX = "Engine protocol predict stream returned an error: "
|
|
13
|
+
ENGINE_MESSAGE = "The model produced output that does not match the expected peg-native format"
|
|
14
|
+
private_constant :ENGINE_PREFIX, :ENGINE_MESSAGE
|
|
15
|
+
|
|
16
|
+
# SDK retries are disabled in a private configuration snapshot. This also
|
|
17
|
+
# disables ordinary 429/500 retries for this selected backend; the generic
|
|
18
|
+
# RubyLLM backend is unchanged. Retries below are opt-in and bounded to one.
|
|
19
|
+
# on_attempt receives safe diagnostic fields, never a prompt or raw body.
|
|
20
|
+
# @api public
|
|
21
|
+
def initialize(context:, max_parse_retries: 0, on_attempt: nil)
|
|
22
|
+
raise ArgumentError, "context must be a RubyLLM::Context" unless context.is_a?(::RubyLLM::Context)
|
|
23
|
+
unless max_parse_retries.is_a?(Integer) && (0..1).cover?(max_parse_retries)
|
|
24
|
+
raise ArgumentError, "max_parse_retries must be 0 or 1"
|
|
25
|
+
end
|
|
26
|
+
raise ArgumentError, "on_attempt must be callable" if on_attempt && !on_attempt.respond_to?(:call)
|
|
27
|
+
|
|
28
|
+
config = context.config.dup
|
|
29
|
+
unless config.openai_protocol == :chat_completions
|
|
30
|
+
raise ArgumentError, "LMStudio requires the Chat Completions protocol"
|
|
31
|
+
end
|
|
32
|
+
config.max_retries = 0
|
|
33
|
+
super(context: ::RubyLLM::Context.new(config.freeze))
|
|
34
|
+
@max_parse_retries = max_parse_retries
|
|
35
|
+
@on_attempt = on_attempt
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
protected
|
|
39
|
+
|
|
40
|
+
def perform_complete(request, cancellation_token:)
|
|
41
|
+
require_openai!(request)
|
|
42
|
+
attempt = 0
|
|
43
|
+
begin
|
|
44
|
+
cancellation_token&.raise_if_cancelled!
|
|
45
|
+
attempt += 1
|
|
46
|
+
response = super
|
|
47
|
+
rescue TransportError => error
|
|
48
|
+
retrying = error.code == PARSE_FAILURE_CODE && retryable_request?(request) &&
|
|
49
|
+
attempt <= @max_parse_retries
|
|
50
|
+
record_attempt(attempt, error: error, retrying: retrying)
|
|
51
|
+
raise unless retrying
|
|
52
|
+
|
|
53
|
+
retry
|
|
54
|
+
end
|
|
55
|
+
record_attempt(attempt)
|
|
56
|
+
response
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def perform_stream(request, cancellation_token:, &block)
|
|
60
|
+
require_openai!(request)
|
|
61
|
+
response = super
|
|
62
|
+
record_attempt(1)
|
|
63
|
+
response
|
|
64
|
+
rescue TransportError => error
|
|
65
|
+
record_attempt(1, error: error)
|
|
66
|
+
raise
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def require_openai!(request)
|
|
72
|
+
unless request.model_config["provider"] == "openai"
|
|
73
|
+
raise ArgumentError, "LMStudio requires provider: openai"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def retryable_request?(request)
|
|
78
|
+
request.model_config["model"] == "openai/gpt-oss-20b" &&
|
|
79
|
+
request.tools.empty? && request.response_schema.nil? &&
|
|
80
|
+
request.messages.none? { |message| message.role == :tool || !message.tool_calls.empty? }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def raise_provider_error(error)
|
|
84
|
+
if provider_parse_failure?(error)
|
|
85
|
+
raise TransportError.new(error.message, code: PARSE_FAILURE_CODE), cause: error
|
|
86
|
+
end
|
|
87
|
+
super
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def provider_parse_failure?(error)
|
|
91
|
+
return false unless error.is_a?(::RubyLLM::BadRequestError) || error.is_a?(::RubyLLM::ServerError)
|
|
92
|
+
|
|
93
|
+
response = error.response
|
|
94
|
+
return false unless response && [400, 500].include?(response.status)
|
|
95
|
+
|
|
96
|
+
body = response.body
|
|
97
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
98
|
+
return false unless body.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
detail = body["error"]
|
|
101
|
+
# Known engine fields can appear directly or inside LM Studio's exact
|
|
102
|
+
# wrapper. These HTTP envelopes are synthetic test fixtures until a live
|
|
103
|
+
# failure is captured; unknown envelopes deliberately remain unclassified.
|
|
104
|
+
if detail.is_a?(Hash) && detail["message"].is_a?(String) && detail["message"].start_with?(ENGINE_PREFIX)
|
|
105
|
+
detail = detail["message"]
|
|
106
|
+
end
|
|
107
|
+
if detail.is_a?(String) && detail.start_with?(ENGINE_PREFIX)
|
|
108
|
+
detail = JSON.parse(detail.delete_prefix(ENGINE_PREFIX))
|
|
109
|
+
end
|
|
110
|
+
detail.is_a?(Hash) && detail["code"] == 500 &&
|
|
111
|
+
detail["type"] == "server_error" && detail["message"] == ENGINE_MESSAGE
|
|
112
|
+
rescue JSON::ParserError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def record_attempt(attempt, error: nil, retrying: false)
|
|
117
|
+
provider = error&.cause
|
|
118
|
+
status = provider.response&.status if provider.is_a?(::RubyLLM::Error)
|
|
119
|
+
event = {attempt: attempt, outcome: error ? :failed : :succeeded,
|
|
120
|
+
code: error&.code, http_status: status, retrying: retrying}.freeze
|
|
121
|
+
warn_attempt(event) if error
|
|
122
|
+
@on_attempt&.call(event)
|
|
123
|
+
rescue
|
|
124
|
+
# Diagnostics never replace the operation result or its original cause.
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def warn_attempt(event)
|
|
129
|
+
warning = "[Phronomy::LLMAdapter::LMStudio] attempt=#{event[:attempt]} " \
|
|
130
|
+
"code=#{event[:code] || "transport_error"} http_status=#{event[:http_status] || "unknown"} retrying=#{event[:retrying]}"
|
|
131
|
+
logger = @sdk_context.config.logger
|
|
132
|
+
logger ? logger.warn(warning) : Kernel.warn(warning)
|
|
133
|
+
rescue
|
|
134
|
+
# A broken logger does not prevent the optional observer from running.
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ruby_llm"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "digest"
|
|
4
6
|
|
|
5
7
|
module Phronomy
|
|
6
8
|
module LLMAdapter
|
|
@@ -8,6 +10,13 @@ module Phronomy
|
|
|
8
10
|
# the backend boundary; a Tool definition never contains executable work.
|
|
9
11
|
# @api public
|
|
10
12
|
class RubyLLM < Base
|
|
13
|
+
# An optional RubyLLM context keeps endpoint/transport settings local to
|
|
14
|
+
# this concrete backend. The default still reads the SDK global settings.
|
|
15
|
+
# @api public
|
|
16
|
+
def initialize(context: nil)
|
|
17
|
+
@sdk_context = context || ::RubyLLM
|
|
18
|
+
end
|
|
19
|
+
|
|
11
20
|
ToolsRequested = Class.new(StandardError) do
|
|
12
21
|
attr_reader :message_value
|
|
13
22
|
def initialize(message_value)
|
|
@@ -53,6 +62,8 @@ module Phronomy
|
|
|
53
62
|
def execute_request(request, token)
|
|
54
63
|
callback_error = nil
|
|
55
64
|
chat = build_chat(request)
|
|
65
|
+
capture = StructuredResponseCapture.new
|
|
66
|
+
capture.bind(chat) if request.response_schema && block_given?
|
|
56
67
|
token&.raise_if_cancelled!
|
|
57
68
|
chat.after_message do |message|
|
|
58
69
|
raise ToolsRequested.new(message) if message.tool_call?
|
|
@@ -72,11 +83,14 @@ module Phronomy
|
|
|
72
83
|
else
|
|
73
84
|
request.message ? chat.ask(request.message) : chat.complete
|
|
74
85
|
end
|
|
75
|
-
response_value(response)
|
|
86
|
+
response_value(response, capture: capture, chat: chat)
|
|
76
87
|
rescue ToolsRequested => requested
|
|
77
|
-
response_value(requested.message_value)
|
|
88
|
+
response_value(requested.message_value, capture: capture, chat: chat)
|
|
78
89
|
rescue => error
|
|
79
90
|
raise error if error.equal?(callback_error)
|
|
91
|
+
if request.response_schema && unsupported_schema_error?(error)
|
|
92
|
+
raise StructuredOutputUnsupportedError, "Provider rejected native structured output"
|
|
93
|
+
end
|
|
80
94
|
raise_provider_error(error)
|
|
81
95
|
end
|
|
82
96
|
|
|
@@ -90,17 +104,56 @@ module Phronomy
|
|
|
90
104
|
elsif config.key?("assume_model_exists")
|
|
91
105
|
options[:assume_model_exists] = config["assume_model_exists"]
|
|
92
106
|
end
|
|
93
|
-
chat =
|
|
107
|
+
chat = @sdk_context.chat(**options)
|
|
108
|
+
if request.response_schema
|
|
109
|
+
validate_structured_support!(request, chat)
|
|
110
|
+
chat.with_schema(request.response_schema)
|
|
111
|
+
end
|
|
94
112
|
chat.with_temperature(config["temperature"]) if config["temperature"]
|
|
95
113
|
chat.with_max_output_tokens(config["max_output_tokens"]) if config["max_output_tokens"]
|
|
96
114
|
if request.system
|
|
97
115
|
chat.with_instructions(request.system, cache_until_here: config["cache_instructions"])
|
|
98
116
|
end
|
|
99
117
|
request.tools.each { |definition| chat.with_tools(tool_declaration(definition)) }
|
|
100
|
-
request.messages.each { |value| chat.messages << sdk_message(value) }
|
|
118
|
+
request.messages.each { |value| chat.messages << sdk_message(value, chat: chat) }
|
|
101
119
|
chat
|
|
102
120
|
end
|
|
103
121
|
|
|
122
|
+
def validate_structured_support!(request, chat)
|
|
123
|
+
provider = chat.model.provider.to_s
|
|
124
|
+
unless %w[openai azure anthropic gemini ollama].include?(provider)
|
|
125
|
+
raise StructuredOutputUnsupportedError, "Native schema transport has not been verified for #{provider}"
|
|
126
|
+
end
|
|
127
|
+
begin
|
|
128
|
+
known = ::RubyLLM.models.find(chat.model.id, provider: provider)
|
|
129
|
+
rescue ::RubyLLM::ModelNotFoundError
|
|
130
|
+
known = nil
|
|
131
|
+
end
|
|
132
|
+
if known && !known.supports?(:structured_output)
|
|
133
|
+
raise StructuredOutputUnsupportedError, "Selected model does not support structured output"
|
|
134
|
+
end
|
|
135
|
+
if known && !request.tools.empty? && !known.supports?(:function_calling)
|
|
136
|
+
raise StructuredOutputUnsupportedError, "Selected model cannot combine the requested schema and Tools"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def unsupported_schema_error?(error)
|
|
141
|
+
return false unless error.is_a?(::RubyLLM::BadRequestError)
|
|
142
|
+
response = error.response if error.respond_to?(:response)
|
|
143
|
+
body = response.respond_to?(:body) ? response.body : response
|
|
144
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
145
|
+
detail = body.is_a?(Hash) ? body["error"] : nil
|
|
146
|
+
return true if detail == "Cannot combine structured output constraints with lazy grammar"
|
|
147
|
+
return false unless detail.is_a?(Hash)
|
|
148
|
+
schema_field = /response_format|output_config\.format|responseJsonSchema|response_schema|text\.format/
|
|
149
|
+
message = detail["message"].to_s
|
|
150
|
+
detail["param"].to_s.match?(/response_format|schema|text\.format|output_config/) ||
|
|
151
|
+
%w[unsupported_response_format unsupported_schema invalid_json_schema].include?(detail["code"]) ||
|
|
152
|
+
(message.match?(schema_field) && message.match?(/unsupported|not supported|invalid|unknown/i))
|
|
153
|
+
rescue JSON::ParserError
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
|
|
104
157
|
def tool_declaration(definition)
|
|
105
158
|
name = definition.fetch("name")
|
|
106
159
|
klass = Class.new(::RubyLLM::Tool) do
|
|
@@ -115,7 +168,7 @@ module Phronomy
|
|
|
115
168
|
klass
|
|
116
169
|
end
|
|
117
170
|
|
|
118
|
-
def sdk_message(value)
|
|
171
|
+
def sdk_message(value, chat:)
|
|
119
172
|
calls = value.tool_calls.to_h do |call|
|
|
120
173
|
[call.id, ::RubyLLM::ToolCall.new(id: call.id, name: call.name,
|
|
121
174
|
arguments: call.arguments, thought_signature: call.metadata["thought_signature"])]
|
|
@@ -125,10 +178,10 @@ module Phronomy
|
|
|
125
178
|
content = "" if value.role == :assistant && content.nil? && !calls.empty?
|
|
126
179
|
::RubyLLM::Message.new(role: value.role, content: content,
|
|
127
180
|
tool_calls: calls.empty? ? nil : calls, tool_call_id: value.tool_call_id,
|
|
128
|
-
model: value.metadata["model_id"])
|
|
181
|
+
model: value.metadata["model_id"], **sdk_continuation(value, chat))
|
|
129
182
|
end
|
|
130
183
|
|
|
131
|
-
def response_value(message)
|
|
184
|
+
def response_value(message, chat:, capture: nil)
|
|
132
185
|
unless message.respond_to?(:content) && message.respond_to?(:role) && message.role.to_sym == :assistant
|
|
133
186
|
raise InvalidResultError, "provider returned an invalid assistant message"
|
|
134
187
|
end
|
|
@@ -150,11 +203,79 @@ module Phronomy
|
|
|
150
203
|
)
|
|
151
204
|
metadata = {}
|
|
152
205
|
metadata["model_id"] = message.model if message.respond_to?(:model) && message.model
|
|
153
|
-
|
|
206
|
+
continuation = continuation_metadata(message, chat)
|
|
207
|
+
metadata["ruby_llm_continuation"] = continuation if continuation
|
|
208
|
+
reason = message.finish_reason if message.respond_to?(:finish_reason)
|
|
209
|
+
raw = message.raw if message.respond_to?(:raw)
|
|
210
|
+
raw = raw.body if raw.respond_to?(:body)
|
|
211
|
+
raw = {} unless raw.is_a?(Hash)
|
|
212
|
+
raw_reason = capture&.raw_finish_reason || raw.dig("choices", 0, "finish_reason") || raw["stop_reason"] ||
|
|
213
|
+
raw.dig("candidates", 0, "finishReason") || reason&.to_s
|
|
214
|
+
refusal = capture&.refusal || raw.dig("choices", 0, "message", "refusal")
|
|
215
|
+
if raw["output"].is_a?(Array)
|
|
216
|
+
refusals = raw["output"].flat_map { |item| Array(item["content"]) }
|
|
217
|
+
.select { |item| item["type"] == "refusal" }.map { |item| item["refusal"] }
|
|
218
|
+
refusal ||= refusals.join("\n") unless refusals.empty?
|
|
219
|
+
end
|
|
220
|
+
refusal ||= message.content.to_s if raw_reason == "refusal"
|
|
221
|
+
normalized = refusal ? :refusal : {stop: :stop, tool_calls: :tool_calls,
|
|
222
|
+
max_tokens: :length, length: :length, content_filter: :content_filter, refusal: :refusal}.fetch(reason&.to_sym, :unknown)
|
|
223
|
+
metadata["raw_finish_reason"] = raw_reason if raw_reason
|
|
224
|
+
Response.new(content: message.content, tool_calls: calls, usage: usage, metadata: metadata,
|
|
225
|
+
finish_reason: normalized, refusal: refusal)
|
|
154
226
|
rescue ArgumentError, TypeError, NoMethodError => error
|
|
155
227
|
raise InvalidResultError, "malformed provider response: #{error.message}"
|
|
156
228
|
end
|
|
157
229
|
|
|
230
|
+
# Only this concrete backend interprets the envelope. Agent persists and
|
|
231
|
+
# materializes Message metadata as immutable JSON, without SDK knowledge.
|
|
232
|
+
def continuation_metadata(message, chat)
|
|
233
|
+
thinking = message.thinking if message.respond_to?(:thinking)
|
|
234
|
+
raw = message.raw_reasoning if message.respond_to?(:raw_reasoning)
|
|
235
|
+
return unless thinking || raw
|
|
236
|
+
|
|
237
|
+
{
|
|
238
|
+
"version" => 1,
|
|
239
|
+
"scope" => continuation_scope(chat),
|
|
240
|
+
"thinking" => thinking && {"text" => thinking.text, "signature" => thinking.signature}.compact,
|
|
241
|
+
"raw_reasoning" => raw
|
|
242
|
+
}.compact
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def sdk_continuation(value, chat)
|
|
246
|
+
return {} unless value.metadata.key?("ruby_llm_continuation")
|
|
247
|
+
|
|
248
|
+
state = value.metadata["ruby_llm_continuation"]
|
|
249
|
+
unless state.is_a?(Hash) && state["version"] == 1 && state["scope"] == continuation_scope(chat)
|
|
250
|
+
raise Phronomy::ConfigurationError,
|
|
251
|
+
"RubyLLM continuation metadata is incompatible with the selected model, provider, endpoint, protocol or format version"
|
|
252
|
+
end
|
|
253
|
+
thinking = state["thinking"]
|
|
254
|
+
valid_thinking = thinking.nil? || (thinking.is_a?(Hash) && (thinking.keys - %w[text signature]).empty? &&
|
|
255
|
+
(thinking["text"].nil? || thinking["text"].is_a?(String)))
|
|
256
|
+
unless value.role == :assistant && valid_thinking &&
|
|
257
|
+
(state.keys - %w[version scope thinking raw_reasoning]).empty?
|
|
258
|
+
raise InvalidResultError, "malformed RubyLLM continuation metadata"
|
|
259
|
+
end
|
|
260
|
+
# Reconstitute SDK-owned values only at the backend boundary. Do not
|
|
261
|
+
# promote arbitrary Message metadata to SDK options or provider params.
|
|
262
|
+
{thinking: thinking, raw_reasoning: state["raw_reasoning"]}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def continuation_scope(chat)
|
|
266
|
+
provider = chat.provider
|
|
267
|
+
protocol = provider.protocols.key(provider.protocol_for(chat.model))
|
|
268
|
+
unless protocol
|
|
269
|
+
raise Phronomy::ConfigurationError, "RubyLLM continuation requires an identifiable protocol"
|
|
270
|
+
end
|
|
271
|
+
{
|
|
272
|
+
"provider" => chat.model.provider.to_s,
|
|
273
|
+
"model" => chat.model.id,
|
|
274
|
+
"protocol" => protocol.to_s,
|
|
275
|
+
"endpoint_sha256" => Digest::SHA256.hexdigest(provider.api_base.to_s)
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
|
|
158
279
|
def stream_value(chunk)
|
|
159
280
|
raise InvalidResultError, "provider returned an invalid stream chunk" unless chunk.respond_to?(:content)
|
|
160
281
|
StreamChunk.new(content: chunk.content)
|
|
@@ -162,8 +283,9 @@ module Phronomy
|
|
|
162
283
|
raise InvalidResultError, "malformed provider chunk: #{error.message}"
|
|
163
284
|
end
|
|
164
285
|
|
|
165
|
-
# Translate SDK
|
|
166
|
-
#
|
|
286
|
+
# Translate identified SDK/HTTP failures only. Callback failures bypass
|
|
287
|
+
# this method even when their class is a transport error. Classification
|
|
288
|
+
# preserves cause; it does not promise that replay would be safe.
|
|
167
289
|
def raise_provider_error(error)
|
|
168
290
|
case error
|
|
169
291
|
when ::RubyLLM::RateLimitError
|
|
@@ -172,8 +294,8 @@ module Phronomy
|
|
|
172
294
|
raise Phronomy::LLMAdapter::AuthenticationError, error.message
|
|
173
295
|
when ::RubyLLM::ContextLengthExceededError
|
|
174
296
|
raise Phronomy::LLMAdapter::ContextLengthError, error.message
|
|
175
|
-
when ::RubyLLM::Error
|
|
176
|
-
raise Phronomy::LLMAdapter::TransportError, error
|
|
297
|
+
when ::RubyLLM::Error, ::Faraday::TimeoutError, ::Faraday::ConnectionFailed
|
|
298
|
+
raise Phronomy::LLMAdapter::TransportError.new(error.message), cause: error
|
|
177
299
|
else
|
|
178
300
|
raise error
|
|
179
301
|
end
|