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