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