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,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../common/error"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Integrations
|
|
7
|
+
# Optional transport helper. Payload meaning and destinations belong to the app.
|
|
8
|
+
# @api public
|
|
9
|
+
class OrderedEventDelivery
|
|
10
|
+
class ClosedError < Phronomy::Error; end
|
|
11
|
+
class OverflowError < Phronomy::Error; end
|
|
12
|
+
|
|
13
|
+
# Close and drain after the block, preserving its result or original failure.
|
|
14
|
+
# A flush timeout limits waiting, not an in-flight network request.
|
|
15
|
+
# @api public
|
|
16
|
+
def self.open(deliver:, capacity: 256, batch_size: 32, flush_timeout: 30, prepare_batch: nil)
|
|
17
|
+
raise ArgumentError, "block required" unless block_given?
|
|
18
|
+
reject_event_loop!
|
|
19
|
+
validate_timeout!(flush_timeout)
|
|
20
|
+
raise ArgumentError, "deliver must be callable" unless deliver.respond_to?(:call)
|
|
21
|
+
delivery = new(capacity: capacity, batch_size: batch_size, prepare_batch: prepare_batch, &deliver)
|
|
22
|
+
begin
|
|
23
|
+
yield delivery
|
|
24
|
+
ensure
|
|
25
|
+
primary = $!
|
|
26
|
+
begin
|
|
27
|
+
delivery.close_and_wait(timeout: flush_timeout)
|
|
28
|
+
rescue => secondary
|
|
29
|
+
if primary
|
|
30
|
+
report_secondary(secondary)
|
|
31
|
+
else
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.reject_event_loop!
|
|
39
|
+
if Phronomy::Runtime.in_event_loop_context?
|
|
40
|
+
raise Phronomy::EventLoopReentrancyError, "delivery flush cannot block EventLoop"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.validate_timeout!(timeout)
|
|
45
|
+
valid = timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
|
|
46
|
+
raise ArgumentError, "flush timeout must be a finite nonnegative number or nil" unless valid
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.report_secondary(error)
|
|
50
|
+
Phronomy.configuration.logger&.error("[OrderedEventDelivery] secondary delivery failure: #{error.class}: #{error.message}")
|
|
51
|
+
rescue
|
|
52
|
+
# Diagnostic reporting must not replace the primary application error.
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
private_class_method :reject_event_loop!, :validate_timeout!, :report_secondary
|
|
56
|
+
|
|
57
|
+
# Construction does not start a worker or create a pool.
|
|
58
|
+
# @api public
|
|
59
|
+
def initialize(capacity: 256, batch_size: 32, prepare_batch: nil, &deliver)
|
|
60
|
+
raise ArgumentError, "deliver block required" unless deliver
|
|
61
|
+
unless capacity.is_a?(Integer) && capacity.positive? && batch_size.is_a?(Integer) && batch_size.positive?
|
|
62
|
+
raise ArgumentError, "capacity and batch_size must be positive Integers"
|
|
63
|
+
end
|
|
64
|
+
raise ArgumentError, "prepare_batch must be callable" if prepare_batch && !prepare_batch.respond_to?(:call)
|
|
65
|
+
@capacity, @batch_size = capacity, batch_size
|
|
66
|
+
@prepare_batch, @deliver = prepare_batch, deliver
|
|
67
|
+
@mutex = Mutex.new
|
|
68
|
+
@condition = ConditionVariable.new
|
|
69
|
+
@queue = []
|
|
70
|
+
@accepting = true
|
|
71
|
+
@worker = nil
|
|
72
|
+
@failure = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Snapshot plain data; admission order is the queue insertion order.
|
|
76
|
+
# Capacity counts queued payloads, excluding the active batch.
|
|
77
|
+
# @api public
|
|
78
|
+
def publish(payload)
|
|
79
|
+
value = immutable_copy(payload)
|
|
80
|
+
ticket = @mutex.synchronize do
|
|
81
|
+
raise @failure if @failure
|
|
82
|
+
raise ClosedError.new("event delivery closed", code: "delivery.closed") unless @accepting
|
|
83
|
+
raise OverflowError.new("event delivery queue full", code: "delivery.capacity_exceeded") if @queue.length >= @capacity
|
|
84
|
+
@queue << value
|
|
85
|
+
next nil if @worker
|
|
86
|
+
@worker = {started: false}
|
|
87
|
+
end
|
|
88
|
+
start_drain(ticket) if ticket
|
|
89
|
+
error = @mutex.synchronize { @failure }
|
|
90
|
+
raise error if error
|
|
91
|
+
self
|
|
92
|
+
rescue => error
|
|
93
|
+
# Listener dispatch can log an exception and continue. Retain the failure
|
|
94
|
+
# for close while continuing to drain already accepted work if possible.
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
@failure ||= error
|
|
97
|
+
@accepting = false
|
|
98
|
+
end
|
|
99
|
+
raise
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# External callers only. Concurrent close calls observe the same drain.
|
|
103
|
+
# Timeout does not abort a network operation or settle the worker's result.
|
|
104
|
+
# @api public
|
|
105
|
+
def close_and_wait(timeout: nil)
|
|
106
|
+
self.class.send(:reject_event_loop!)
|
|
107
|
+
self.class.send(:validate_timeout!, timeout)
|
|
108
|
+
deadline = timeout && monotonic_now + timeout
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
@accepting = false
|
|
111
|
+
while @worker
|
|
112
|
+
remaining = deadline && deadline - monotonic_now
|
|
113
|
+
raise Phronomy::TimeoutError, "event delivery flush timed out" if remaining && remaining <= 0
|
|
114
|
+
@condition.wait(@mutex, remaining)
|
|
115
|
+
end
|
|
116
|
+
raise @failure if @failure
|
|
117
|
+
end
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def start_drain(ticket)
|
|
124
|
+
Phronomy::Blocking.call_async do
|
|
125
|
+
started = @mutex.synchronize do
|
|
126
|
+
ticket[:started] = true if @worker.equal?(ticket)
|
|
127
|
+
end
|
|
128
|
+
if started
|
|
129
|
+
begin
|
|
130
|
+
drain(ticket)
|
|
131
|
+
rescue => error
|
|
132
|
+
fail_worker(ticket, error)
|
|
133
|
+
ensure
|
|
134
|
+
finish_worker(ticket)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end.on_complete do |_value, error|
|
|
138
|
+
fail_worker(ticket, error) if error
|
|
139
|
+
end
|
|
140
|
+
rescue => error
|
|
141
|
+
fail_worker(ticket, error)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def drain(ticket)
|
|
145
|
+
loop do
|
|
146
|
+
batch = @mutex.synchronize do
|
|
147
|
+
if @queue.empty?
|
|
148
|
+
# Publish must observe queue emptiness and ownership release together.
|
|
149
|
+
release_worker(ticket)
|
|
150
|
+
nil
|
|
151
|
+
else
|
|
152
|
+
@queue.shift(@batch_size)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
return unless batch
|
|
156
|
+
prepared = @prepare_batch ? @prepare_batch.call(batch.freeze) : batch
|
|
157
|
+
raise ArgumentError, "prepare_batch must return an Array" unless prepared.is_a?(Array)
|
|
158
|
+
immutable_copy(prepared).each { |payload| @deliver.call(payload) }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def fail_worker(ticket, error)
|
|
163
|
+
@mutex.synchronize do
|
|
164
|
+
@failure ||= error
|
|
165
|
+
@accepting = false
|
|
166
|
+
@queue.clear
|
|
167
|
+
# Logical cancellation can precede physical worker exit. Keep waiting
|
|
168
|
+
# for a started delivery; never mistake settled TaskResult for exit.
|
|
169
|
+
release_worker(ticket) unless ticket[:started]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def finish_worker(ticket)
|
|
174
|
+
@mutex.synchronize { release_worker(ticket) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def release_worker(ticket)
|
|
178
|
+
return unless @worker.equal?(ticket)
|
|
179
|
+
@worker = nil
|
|
180
|
+
@condition.broadcast
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def immutable_copy(value, ancestors = {})
|
|
184
|
+
case value
|
|
185
|
+
when Hash, Array
|
|
186
|
+
raise ArgumentError, "event payload must contain acyclic plain data" if ancestors[value.object_id]
|
|
187
|
+
path = ancestors.merge(value.object_id => true)
|
|
188
|
+
if value.is_a?(Hash)
|
|
189
|
+
value.to_h { |key, child| [immutable_copy(key, path), immutable_copy(child, path)] }.freeze
|
|
190
|
+
else
|
|
191
|
+
value.map { |child| immutable_copy(child, path) }.freeze
|
|
192
|
+
end
|
|
193
|
+
when String then value.dup.freeze
|
|
194
|
+
when Integer, Symbol, TrueClass, FalseClass, NilClass then value
|
|
195
|
+
when Float
|
|
196
|
+
raise ArgumentError, "event payload numbers must be finite" unless value.finite?
|
|
197
|
+
value
|
|
198
|
+
else
|
|
199
|
+
raise ArgumentError, "event payload must contain plain data: #{value.class}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def monotonic_now
|
|
204
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module LLMAdapter
|
|
5
|
+
# RubyLLM 2.0 drops refusal fields when creating streaming Chunk values.
|
|
6
|
+
# Use its Protocol subclass seam on this operation's Provider only; never
|
|
7
|
+
# patch global SDK classes or retain an application callback across calls.
|
|
8
|
+
# @api private
|
|
9
|
+
class StructuredResponseCapture
|
|
10
|
+
attr_reader :refusal, :raw_finish_reason
|
|
11
|
+
|
|
12
|
+
def bind(chat)
|
|
13
|
+
capture = self
|
|
14
|
+
protocols = chat.provider.protocols.transform_values do |base|
|
|
15
|
+
Class.new(base) do
|
|
16
|
+
define_method(:build_chunk) do |data|
|
|
17
|
+
capture.record(data)
|
|
18
|
+
super(data)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end.freeze
|
|
22
|
+
chat.provider.define_singleton_method(:protocols) { protocols }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record(data)
|
|
26
|
+
choice = data.dig("choices", 0) || {}
|
|
27
|
+
@raw_finish_reason = choice["finish_reason"] || data.dig("delta", "stop_reason") ||
|
|
28
|
+
data.dig("candidates", 0, "finishReason") || @raw_finish_reason
|
|
29
|
+
delta = choice.dig("delta", "refusal")
|
|
30
|
+
delta ||= data["delta"] if data["type"] == "response.refusal.delta"
|
|
31
|
+
@refusal = (@refusal || "") + delta if delta.is_a?(String)
|
|
32
|
+
final = data["response"]
|
|
33
|
+
return unless final.is_a?(Hash)
|
|
34
|
+
refusals = Array(final["output"]).flat_map { |item| Array(item["content"]) }
|
|
35
|
+
.select { |item| item["type"] == "refusal" }.map { |item| item["refusal"] }
|
|
36
|
+
@refusal = refusals.join("\n") unless refusals.empty?
|
|
37
|
+
@raw_finish_reason = final.dig("incomplete_details", "reason") || final["status"] || @raw_finish_reason
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -7,6 +7,10 @@ require_relative "../tool/call_request"
|
|
|
7
7
|
module Phronomy
|
|
8
8
|
module LLMAdapter
|
|
9
9
|
# Provider-independent conversation value, with no executable capabilities.
|
|
10
|
+
# Metadata is immutable JSON. Backend-owned continuation values travel with
|
|
11
|
+
# the message through Agent persistence/import; only their owning backend
|
|
12
|
+
# interprets their format and replay compatibility. They grant no authority
|
|
13
|
+
# and are not answer content or arbitrary provider request options.
|
|
10
14
|
# @api public
|
|
11
15
|
class Message
|
|
12
16
|
attr_reader :role, :content, :tool_calls, :tool_call_id, :metadata
|
|
@@ -7,10 +7,10 @@ module Phronomy
|
|
|
7
7
|
# Immutable input for one model turn, including inert Tool definitions.
|
|
8
8
|
# @api public
|
|
9
9
|
class Request
|
|
10
|
-
attr_reader :model_config, :system, :messages, :tools, :message
|
|
10
|
+
attr_reader :model_config, :system, :messages, :tools, :message, :response_schema
|
|
11
11
|
|
|
12
12
|
# @api public
|
|
13
|
-
def initialize(model_config: {}, system: nil, messages: [], tools: [], message: nil)
|
|
13
|
+
def initialize(model_config: {}, system: nil, messages: [], tools: [], message: nil, response_schema: nil)
|
|
14
14
|
if !(model_config.is_a?(Hash) && (system.nil? || system.is_a?(String)) && (message.nil? || message.is_a?(String)))
|
|
15
15
|
raise ArgumentError, "invalid LLM request configuration or text"
|
|
16
16
|
end
|
|
@@ -36,6 +36,11 @@ module Phronomy
|
|
|
36
36
|
names = normalized_tools.map { |tool| tool.fetch("name") }
|
|
37
37
|
raise ArgumentError, "duplicate Tool definition name" unless names.uniq == names
|
|
38
38
|
config = normalize_keys(model_config)
|
|
39
|
+
@response_schema = response_schema.nil? ? nil : Phronomy::Values::Immutable.json(response_schema)
|
|
40
|
+
if @response_schema && !@response_schema.is_a?(Hash)
|
|
41
|
+
raise ArgumentError, "response_schema must be a JSON object or nil"
|
|
42
|
+
end
|
|
43
|
+
reject_schema_options!(config) if @response_schema
|
|
39
44
|
Phronomy::Values::Immutable.validate_canonical_json!(config, label: "LLM model configuration")
|
|
40
45
|
max = config["max_output_tokens"]
|
|
41
46
|
if !max.nil? && (!max.is_a?(Integer) || max <= 0)
|
|
@@ -64,6 +69,16 @@ module Phronomy
|
|
|
64
69
|
|
|
65
70
|
private
|
|
66
71
|
|
|
72
|
+
def reject_schema_options!(value)
|
|
73
|
+
return unless value.is_a?(Hash)
|
|
74
|
+
value.each do |key, child|
|
|
75
|
+
if %w[schema output_schema response_schema response_format responseJsonSchema responseSchema].include?(key.to_s)
|
|
76
|
+
raise ArgumentError, "model configuration cannot override response_schema"
|
|
77
|
+
end
|
|
78
|
+
reject_schema_options!(child)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
67
82
|
def normalize_keys(value)
|
|
68
83
|
value.each_with_object({}) do |(key, child), result|
|
|
69
84
|
unless key.is_a?(String) || key.is_a?(Symbol)
|