rlm-rb 0.1.0 → 0.2.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 +45 -2
- data/README.md +157 -55
- data/examples/plain_ruby_invoice_extraction.rb +85 -0
- data/lib/rlm/code_extractor.rb +125 -0
- data/lib/rlm/file.rb +1 -1
- data/lib/rlm/lm/mock.rb +45 -0
- data/lib/rlm/lm/ruby_llm.rb +99 -0
- data/lib/rlm/predict.rb +18 -9
- data/lib/rlm/prompt_builder.rb +199 -0
- data/lib/rlm/runtime/bridge.rb +146 -0
- data/lib/rlm/runtime/signature_registry.rb +75 -0
- data/lib/rlm/runtime.rb +352 -0
- data/lib/rlm/sandbox/unsafe_in_process.rb +116 -0
- data/lib/rlm/signature/dspy.rb +155 -0
- data/lib/rlm/signature.rb +76 -0
- data/lib/rlm/trace.rb +2 -0
- data/lib/rlm/version.rb +1 -1
- data/lib/rlm.rb +9 -0
- metadata +66 -10
data/lib/rlm/runtime.rb
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "code_extractor"
|
|
6
|
+
require_relative "context"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "file"
|
|
9
|
+
require_relative "limits"
|
|
10
|
+
require_relative "prompt_builder"
|
|
11
|
+
require_relative "result"
|
|
12
|
+
require_relative "runtime/bridge"
|
|
13
|
+
require_relative "runtime/signature_registry"
|
|
14
|
+
require_relative "signature"
|
|
15
|
+
require_relative "trace"
|
|
16
|
+
|
|
17
|
+
module RLM
|
|
18
|
+
# rubocop:disable Metrics/ClassLength
|
|
19
|
+
class Runtime
|
|
20
|
+
def initialize(
|
|
21
|
+
signature:,
|
|
22
|
+
input:,
|
|
23
|
+
lm:,
|
|
24
|
+
sandbox:,
|
|
25
|
+
limits:,
|
|
26
|
+
sub_lm: nil,
|
|
27
|
+
context: nil,
|
|
28
|
+
tools: [],
|
|
29
|
+
skills: [],
|
|
30
|
+
validators: [],
|
|
31
|
+
signatures: [],
|
|
32
|
+
depth: 0,
|
|
33
|
+
trace_store: nil
|
|
34
|
+
)
|
|
35
|
+
@signature = signature
|
|
36
|
+
@input = input || {}
|
|
37
|
+
@lm = lm
|
|
38
|
+
@sub_lm = sub_lm || lm
|
|
39
|
+
@sandbox = sandbox
|
|
40
|
+
@limits = limits || Limits.new
|
|
41
|
+
@context = context || build_context(@input)
|
|
42
|
+
@tools = Array(tools)
|
|
43
|
+
@skills = Array(skills)
|
|
44
|
+
@validators = Array(validators)
|
|
45
|
+
@signatures = SignatureRegistry.build(signature, signatures)
|
|
46
|
+
@depth = depth
|
|
47
|
+
@trace_store = trace_store
|
|
48
|
+
@trace = Trace.new
|
|
49
|
+
@iterations = 0
|
|
50
|
+
@llm_calls = 0
|
|
51
|
+
@sub_lm_calls = 0
|
|
52
|
+
@tool_calls = 0
|
|
53
|
+
@last_submitted_output = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call
|
|
57
|
+
raise ProviderError, "root LM is required" if lm.nil?
|
|
58
|
+
|
|
59
|
+
start_run
|
|
60
|
+
bridge = prepare_sandbox
|
|
61
|
+
run_loop(bridge)
|
|
62
|
+
rescue BudgetExceededError => e
|
|
63
|
+
budget_exceeded_result(e)
|
|
64
|
+
rescue ToolError => e
|
|
65
|
+
finish(:tool_error, error: e)
|
|
66
|
+
rescue ProviderError => e
|
|
67
|
+
finish(:provider_error, error: e)
|
|
68
|
+
rescue SandboxError => e
|
|
69
|
+
finish(:sandbox_error, error: e)
|
|
70
|
+
rescue ValidationError => e
|
|
71
|
+
validation_failure([e.message], e)
|
|
72
|
+
rescue ParseError, ConfigurationError => e
|
|
73
|
+
finish(:aborted, error: e)
|
|
74
|
+
ensure
|
|
75
|
+
sandbox&.cleanup
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def predict_subcall(signature, input, depth:)
|
|
79
|
+
raise BudgetExceededError, "max_recursion_depth exceeded" if depth > limits.max_recursion_depth
|
|
80
|
+
raise ProviderError, "sub LM is required" if sub_lm.nil?
|
|
81
|
+
|
|
82
|
+
parsed = call_sub_lm(signature, input, depth)
|
|
83
|
+
raise ValidationError, "sub LM must return <rlm-final> in v0.2 mock runtime" unless parsed.final?
|
|
84
|
+
|
|
85
|
+
output = Signature.coerce_output(signature, parsed.content)
|
|
86
|
+
validate_output!(signature, output)
|
|
87
|
+
output
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def record_tool_attempt!
|
|
91
|
+
trace.record(:budget_checked, budget: :tool_calls, current: @tool_calls, limit: limits.max_tool_calls)
|
|
92
|
+
raise BudgetExceededError, "max_tool_calls exceeded" if @tool_calls >= limits.max_tool_calls
|
|
93
|
+
|
|
94
|
+
@tool_calls += 1
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def record_submitted_output(output)
|
|
98
|
+
@last_submitted_output = output
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
attr_reader :signature, :input, :lm, :sub_lm, :context, :tools, :skills,
|
|
104
|
+
:sandbox, :limits, :validators, :signatures, :depth, :trace,
|
|
105
|
+
:iterations, :llm_calls, :sub_lm_calls, :trace_store
|
|
106
|
+
|
|
107
|
+
def start_run
|
|
108
|
+
trace.record(:run_started, signature: Signature.name_for(signature), input: input)
|
|
109
|
+
validate_root_input!
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def prepare_sandbox
|
|
113
|
+
bridge = Bridge.new(
|
|
114
|
+
runtime: self,
|
|
115
|
+
context: context,
|
|
116
|
+
trace: trace,
|
|
117
|
+
tools: tools,
|
|
118
|
+
signatures: signatures,
|
|
119
|
+
depth: depth
|
|
120
|
+
)
|
|
121
|
+
sandbox.prepare(context: context, tools: tools, skills: skills, runtime_bridge: bridge)
|
|
122
|
+
bridge
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def run_loop(bridge)
|
|
126
|
+
loop do
|
|
127
|
+
ensure_time_budget!
|
|
128
|
+
parsed = call_root_lm
|
|
129
|
+
return complete(parsed.content) if parsed.final?
|
|
130
|
+
|
|
131
|
+
execute_code(parsed.content)
|
|
132
|
+
return complete(bridge.submitted_output) unless bridge.submitted_output.nil?
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def call_root_lm
|
|
137
|
+
ensure_llm_budget!
|
|
138
|
+
prompt = PromptBuilder.build(signature, input: input, context: context, limits: limits)
|
|
139
|
+
trace.record(:root_prompt_created, bytes: prompt.bytesize)
|
|
140
|
+
response = call_lm(lm, :root_lm_called, signature, prompt, depth)
|
|
141
|
+
CodeExtractor.extract(response)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def call_sub_lm(checked_signature, payload, sub_depth)
|
|
145
|
+
ensure_llm_budget!
|
|
146
|
+
ensure_sub_lm_budget!
|
|
147
|
+
prompt = PromptBuilder.build(checked_signature, input: payload, context: context, limits: limits)
|
|
148
|
+
response = call_lm(sub_lm, :sub_lm_called, checked_signature, prompt, sub_depth)
|
|
149
|
+
@sub_lm_calls += 1
|
|
150
|
+
CodeExtractor.extract(response)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def call_lm(candidate, event_type, checked_signature, prompt, call_depth)
|
|
154
|
+
before_cost = candidate.cost_cents if candidate.respond_to?(:cost_cents)
|
|
155
|
+
response = candidate.call(prompt: prompt, signature: Signature.name_for(checked_signature), depth: call_depth)
|
|
156
|
+
@llm_calls += 1
|
|
157
|
+
payload = {
|
|
158
|
+
signature: Signature.name_for(checked_signature),
|
|
159
|
+
cost_cents: cost_delta(candidate, before_cost)
|
|
160
|
+
}
|
|
161
|
+
payload[:usage] = candidate.last_usage if candidate.respond_to?(:last_usage) && candidate.last_usage
|
|
162
|
+
trace.record(event_type, payload)
|
|
163
|
+
ensure_cost_budget!
|
|
164
|
+
response
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def execute_code(code)
|
|
168
|
+
ensure_time_budget!
|
|
169
|
+
trace.record(:budget_checked, budget: :iterations, current: iterations, limit: limits.max_iterations)
|
|
170
|
+
raise BudgetExceededError, "max_iterations exceeded" if iterations >= limits.max_iterations
|
|
171
|
+
|
|
172
|
+
@iterations += 1
|
|
173
|
+
trace.record(:code_generated, code: code)
|
|
174
|
+
result = sandbox.exec(code)
|
|
175
|
+
ensure_stdout_budget!(result)
|
|
176
|
+
trace.record(:code_executed, result: result.to_h)
|
|
177
|
+
handle_sandbox_result!(result)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def handle_sandbox_result!(result)
|
|
181
|
+
return if result.ok?
|
|
182
|
+
|
|
183
|
+
case result.error
|
|
184
|
+
when BudgetExceededError, ParseError, ToolError
|
|
185
|
+
raise result.error
|
|
186
|
+
else
|
|
187
|
+
raise SandboxError, result.error&.message || result.stderr || "sandbox execution failed"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def complete(output)
|
|
192
|
+
coerced_output = Signature.coerce_output(signature, output)
|
|
193
|
+
ensure_output_budget!(coerced_output)
|
|
194
|
+
errors = validate_output(signature, coerced_output)
|
|
195
|
+
return validation_failure(errors) unless errors.empty?
|
|
196
|
+
|
|
197
|
+
trace.record(:run_completed, status: :completed)
|
|
198
|
+
finish(:completed, output: coerced_output)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def validate_root_input!
|
|
202
|
+
trace.record(:validation_attempted, signature: Signature.name_for(signature), direction: :input)
|
|
203
|
+
errors = Signature.validate_input(signature, input)
|
|
204
|
+
return if errors.empty?
|
|
205
|
+
|
|
206
|
+
trace.record(:validation_failed, signature: Signature.name_for(signature), direction: :input, errors: errors)
|
|
207
|
+
raise ValidationError, errors.join(", ")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def validate_output!(checked_signature, output)
|
|
211
|
+
errors = validate_output(checked_signature, output)
|
|
212
|
+
raise ValidationError, errors.join(", ") unless errors.empty?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def validate_output(checked_signature, output)
|
|
216
|
+
trace.record(:validation_attempted, signature: Signature.name_for(checked_signature), direction: :output)
|
|
217
|
+
all_errors = Signature.validate_output(checked_signature, output) + custom_validation_errors(output)
|
|
218
|
+
record_validation_failure(checked_signature, all_errors) unless all_errors.empty?
|
|
219
|
+
all_errors
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def custom_validation_errors(output)
|
|
223
|
+
validators.flat_map { |validator| Array(validator.call(output)) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def record_validation_failure(checked_signature, errors)
|
|
227
|
+
trace.record(
|
|
228
|
+
:validation_failed,
|
|
229
|
+
signature: Signature.name_for(checked_signature),
|
|
230
|
+
direction: :output,
|
|
231
|
+
errors: errors
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def validation_failure(errors, error = nil)
|
|
236
|
+
finish(:failed_validation, validation_errors: errors, error: error)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def finish(status, output: nil, error: nil, validation_errors: [])
|
|
240
|
+
record_run_failed(status, error:, validation_errors:) unless status == :completed
|
|
241
|
+
|
|
242
|
+
result = Result.new(
|
|
243
|
+
trace: trace,
|
|
244
|
+
status: status,
|
|
245
|
+
output: output,
|
|
246
|
+
error: error,
|
|
247
|
+
cost_cents: runtime_cost_cents,
|
|
248
|
+
duration_ms: trace.duration_ms,
|
|
249
|
+
llm_calls: llm_calls,
|
|
250
|
+
iterations: iterations,
|
|
251
|
+
validation_errors: validation_errors
|
|
252
|
+
)
|
|
253
|
+
persist_trace(result)
|
|
254
|
+
result
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def ensure_llm_budget!
|
|
258
|
+
trace.record(:budget_checked, budget: :llm_calls, current: llm_calls, limit: limits.max_llm_calls)
|
|
259
|
+
raise BudgetExceededError, "max_llm_calls exceeded" if llm_calls >= limits.max_llm_calls
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def ensure_sub_lm_budget!
|
|
263
|
+
trace.record(:budget_checked, budget: :sub_lm_calls, current: sub_lm_calls, limit: limits.max_sub_lm_calls)
|
|
264
|
+
raise BudgetExceededError, "max_sub_lm_calls exceeded" if sub_lm_calls >= limits.max_sub_lm_calls
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def ensure_cost_budget!
|
|
268
|
+
current_cost = runtime_cost_cents
|
|
269
|
+
trace.record(:budget_checked, budget: :cost_cents, current: current_cost, limit: limits.max_cost_cents)
|
|
270
|
+
raise BudgetExceededError, "max_cost_cents exceeded" if current_cost >= limits.max_cost_cents
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def ensure_time_budget!
|
|
274
|
+
current_ms = trace.duration_ms
|
|
275
|
+
limit_ms = limits.max_runtime_seconds * 1000
|
|
276
|
+
trace.record(:budget_checked, budget: :runtime_seconds, current: current_ms, limit: limit_ms)
|
|
277
|
+
raise BudgetExceededError, "max_runtime_seconds exceeded" if current_ms >= limit_ms
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def ensure_output_budget!(output)
|
|
281
|
+
current_bytes = JSON.generate(output).bytesize
|
|
282
|
+
trace.record(:budget_checked, budget: :output_bytes, current: current_bytes, limit: limits.max_output_bytes)
|
|
283
|
+
raise BudgetExceededError, "max_output_bytes exceeded" if current_bytes > limits.max_output_bytes
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def ensure_stdout_budget!(result)
|
|
287
|
+
current_bytes = result.stdout.to_s.bytesize
|
|
288
|
+
trace.record(:budget_checked, budget: :stdout_bytes, current: current_bytes, limit: limits.max_stdout_bytes)
|
|
289
|
+
raise BudgetExceededError, "max_stdout_bytes exceeded" if current_bytes > limits.max_stdout_bytes
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def budget_exceeded_result(error)
|
|
293
|
+
case limits.on_budget_exceeded
|
|
294
|
+
when :needs_review
|
|
295
|
+
finish(:needs_review, output: valid_last_submitted_output, error: error)
|
|
296
|
+
when :return_partial
|
|
297
|
+
output = valid_last_submitted_output
|
|
298
|
+
return finish(:needs_review, output: output, error: error) unless output.nil?
|
|
299
|
+
|
|
300
|
+
finish(:budget_exceeded, error: error)
|
|
301
|
+
else
|
|
302
|
+
finish(:budget_exceeded, error: error)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def valid_last_submitted_output
|
|
307
|
+
return if @last_submitted_output.nil?
|
|
308
|
+
return if validate_output(signature, @last_submitted_output).any?
|
|
309
|
+
|
|
310
|
+
ensure_output_budget!(@last_submitted_output)
|
|
311
|
+
@last_submitted_output
|
|
312
|
+
rescue BudgetExceededError
|
|
313
|
+
nil
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def persist_trace(result)
|
|
317
|
+
return unless trace_store.respond_to?(:call)
|
|
318
|
+
|
|
319
|
+
trace_store.call(result)
|
|
320
|
+
rescue StandardError
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def record_run_failed(status, error:, validation_errors: [])
|
|
325
|
+
payload = { status: status }
|
|
326
|
+
payload[:error] = trace_error_payload(error) if error
|
|
327
|
+
payload[:errors] = validation_errors if validation_errors.any?
|
|
328
|
+
trace.record(:run_failed, payload)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def trace_error_payload(error)
|
|
332
|
+
{ class: error.class.name, message: error.message }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def runtime_cost_cents
|
|
336
|
+
[lm, sub_lm].compact.uniq.sum do |candidate|
|
|
337
|
+
candidate.respond_to?(:cost_cents) ? candidate.cost_cents : 0
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def cost_delta(candidate, before_cost)
|
|
342
|
+
return 0 unless candidate.respond_to?(:cost_cents)
|
|
343
|
+
|
|
344
|
+
candidate.cost_cents - before_cost.to_i
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def build_context(payload)
|
|
348
|
+
Context.new(inputs: payload, files: payload.values.grep(RLM::File))
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
# rubocop:enable Metrics/ClassLength
|
|
352
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARNING: This sandbox is intentionally unsafe.
|
|
4
|
+
#
|
|
5
|
+
# It executes model-produced Ruby code with instance_eval inside the current Ruby process.
|
|
6
|
+
# It provides no process isolation, memory isolation, filesystem isolation, network isolation,
|
|
7
|
+
# timeout enforcement, or protection from malicious code. Use it only in development/tests to
|
|
8
|
+
# prove the runtime spine. Production backends must use isolated subprocess, container, or remote
|
|
9
|
+
# runners instead. Stream capture mutates process-global $stdout/$stderr, so this class
|
|
10
|
+
# serializes execution with a mutex; it is still not a concurrency-safe production sandbox.
|
|
11
|
+
|
|
12
|
+
require "stringio"
|
|
13
|
+
|
|
14
|
+
module RLM
|
|
15
|
+
module Sandbox
|
|
16
|
+
class UnsafeInProcess < Base
|
|
17
|
+
STREAM_CAPTURE_MUTEX = Mutex.new
|
|
18
|
+
|
|
19
|
+
attr_reader :context, :tools, :skills, :runtime_bridge
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
super
|
|
23
|
+
@prepared = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def prepared?
|
|
27
|
+
@prepared
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def prepare(context:, tools:, skills:, runtime_bridge:)
|
|
31
|
+
@context = context
|
|
32
|
+
@tools = tools
|
|
33
|
+
@skills = skills
|
|
34
|
+
@runtime_bridge = runtime_bridge
|
|
35
|
+
@prepared = true
|
|
36
|
+
ExecutionResult.new(status: :ok)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def exec(code)
|
|
40
|
+
raise SandboxError, "Sandbox not prepared" unless prepared?
|
|
41
|
+
|
|
42
|
+
execute(code)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def execute(code)
|
|
46
|
+
stdout, stderr = capture_streams do
|
|
47
|
+
Scope.new(runtime_bridge).instance_eval(code, "(rlm unsafe in-process sandbox)")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
ExecutionResult.new(status: :ok, stdout: stdout, stderr: stderr)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
ExecutionResult.new(status: :error, stderr: e.message, error: e, exit_code: 1)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cleanup
|
|
56
|
+
@context = nil
|
|
57
|
+
@tools = nil
|
|
58
|
+
@skills = nil
|
|
59
|
+
@runtime_bridge = nil
|
|
60
|
+
@prepared = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def capture_streams
|
|
66
|
+
STREAM_CAPTURE_MUTEX.synchronize do
|
|
67
|
+
old_stdout = $stdout
|
|
68
|
+
old_stderr = $stderr
|
|
69
|
+
captured_stdout = StringIO.new
|
|
70
|
+
captured_stderr = StringIO.new
|
|
71
|
+
$stdout = captured_stdout
|
|
72
|
+
$stderr = captured_stderr
|
|
73
|
+
yield
|
|
74
|
+
[captured_stdout.string, captured_stderr.string]
|
|
75
|
+
ensure
|
|
76
|
+
$stdout = old_stdout
|
|
77
|
+
$stderr = old_stderr
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class Scope
|
|
82
|
+
def initialize(runtime_bridge)
|
|
83
|
+
@runtime_bridge = runtime_bridge
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def predict(signature_name, input_hash)
|
|
87
|
+
runtime_bridge.predict(signature_name, input_hash)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def tool(tool_name, input_hash)
|
|
91
|
+
runtime_bridge.tool(tool_name, input_hash)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def submit(output_hash)
|
|
95
|
+
runtime_bridge.submit(output_hash)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def read_file(handle)
|
|
99
|
+
runtime_bridge.read_file(handle)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def list_files
|
|
103
|
+
runtime_bridge.list_files
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def log(message)
|
|
107
|
+
runtime_bridge.log(message)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
attr_reader :runtime_bridge
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RLM
|
|
4
|
+
module Signature
|
|
5
|
+
class Dspy
|
|
6
|
+
TYPE_MAP = {
|
|
7
|
+
"array" => :array,
|
|
8
|
+
"boolean" => :boolean,
|
|
9
|
+
"integer" => :integer,
|
|
10
|
+
"number" => :number,
|
|
11
|
+
"object" => :object,
|
|
12
|
+
"string" => :string
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(signature)
|
|
16
|
+
@signature = signature
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def name
|
|
20
|
+
return signature.name if signature.respond_to?(:name) && !signature.name.to_s.empty?
|
|
21
|
+
|
|
22
|
+
signature.to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def description
|
|
26
|
+
return signature.description if signature.respond_to?(:description)
|
|
27
|
+
|
|
28
|
+
name
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def input_fields
|
|
32
|
+
fields_for(input_schema)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def output_fields
|
|
36
|
+
fields_for(output_schema)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_input(input)
|
|
40
|
+
validate_payload(input, input_schema)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_output(output)
|
|
44
|
+
validate_payload(output, output_schema)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def coerce_output(output)
|
|
48
|
+
return output unless output.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
schema_keys = output_fields.keys
|
|
51
|
+
output.each_with_object({}) do |(key, value), coerced|
|
|
52
|
+
coerced_key = schema_keys.find { |schema_key| schema_key.to_s == key.to_s } || key
|
|
53
|
+
coerced[coerced_key] = value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
attr_reader :signature
|
|
60
|
+
|
|
61
|
+
def input_schema
|
|
62
|
+
schema_from(:input_json_schema, :input_schema)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def output_schema
|
|
66
|
+
schema_from(:output_json_schema, :output_schema)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def schema_from(*method_names)
|
|
70
|
+
method_names.each do |method_name|
|
|
71
|
+
next unless signature.respond_to?(method_name)
|
|
72
|
+
|
|
73
|
+
schema = signature.public_send(method_name)
|
|
74
|
+
return normalize_hash(schema) if schema.is_a?(Hash)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
raise ConfigurationError, "dspy signature #{name} does not expose JSON schema metadata"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fields_for(schema)
|
|
81
|
+
properties_for(schema).each_with_object({}) do |(field_name, metadata), fields|
|
|
82
|
+
fields[field_name.to_sym] = field_type(metadata)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_payload(payload, schema)
|
|
87
|
+
return ["payload must be a Hash"] unless payload.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
normalized = normalize_hash(payload)
|
|
90
|
+
required_errors(schema, normalized) + type_errors(schema, normalized)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def required_errors(schema, payload)
|
|
94
|
+
required_fields(schema).filter_map do |field_name|
|
|
95
|
+
"#{field_name} is required" unless payload.key?(field_name.to_s) || payload.key?(field_name.to_sym)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def type_errors(schema, payload)
|
|
100
|
+
properties_for(schema).filter_map do |field_name, metadata|
|
|
101
|
+
value = fetch_payload_value(payload, field_name)
|
|
102
|
+
next if value.nil? || value_matches_type?(value, field_type(metadata))
|
|
103
|
+
|
|
104
|
+
"#{field_name} must be #{field_type(metadata)}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def fetch_payload_value(payload, field_name)
|
|
109
|
+
payload.fetch(field_name.to_s) { payload[field_name.to_sym] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def required_fields(schema)
|
|
113
|
+
Array(schema["required"] || schema[:required]).map(&:to_s)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def properties_for(schema)
|
|
117
|
+
normalize_hash(schema["properties"] || schema[:properties] || {})
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def field_type(metadata)
|
|
121
|
+
normalized = normalize_hash(metadata || {})
|
|
122
|
+
type = normalized["type"] || normalized[:type]
|
|
123
|
+
TYPE_MAP.fetch(type.to_s, type.to_s.empty? ? :object : type.to_s.to_sym)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def value_matches_type?(value, type)
|
|
127
|
+
case type
|
|
128
|
+
when :array
|
|
129
|
+
value.is_a?(Array)
|
|
130
|
+
when :boolean
|
|
131
|
+
boolean?(value)
|
|
132
|
+
when :integer
|
|
133
|
+
value.is_a?(Integer)
|
|
134
|
+
when :number
|
|
135
|
+
value.is_a?(Numeric)
|
|
136
|
+
when :object
|
|
137
|
+
value.is_a?(Hash)
|
|
138
|
+
when :string
|
|
139
|
+
value.is_a?(String)
|
|
140
|
+
else true
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def boolean?(value)
|
|
145
|
+
[true, false].include?(value)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def normalize_hash(hash)
|
|
149
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
150
|
+
normalized[key] = value.is_a?(Hash) ? normalize_hash(value) : value
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module RLM
|
|
6
|
+
module Signature
|
|
7
|
+
REQUIRED_METHODS = %i[
|
|
8
|
+
description
|
|
9
|
+
input_fields
|
|
10
|
+
output_fields
|
|
11
|
+
validate_input
|
|
12
|
+
validate_output
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def validate_interface!(signature)
|
|
18
|
+
missing = REQUIRED_METHODS.reject { |method_name| signature.respond_to?(method_name) }
|
|
19
|
+
raise ConfigurationError, "signature is missing required methods: #{missing.join(", ")}" unless missing.empty?
|
|
20
|
+
|
|
21
|
+
validate_fields!(signature, :input_fields)
|
|
22
|
+
validate_fields!(signature, :output_fields)
|
|
23
|
+
signature
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate_input(signature, input)
|
|
27
|
+
validate_payload(signature, input, :validate_input)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_output(signature, output)
|
|
31
|
+
validate_payload(signature, output, :validate_output)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def coerce_output(signature, output)
|
|
35
|
+
return signature.coerce_output(output) if signature.respond_to?(:coerce_output)
|
|
36
|
+
|
|
37
|
+
output
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def assert_valid_input!(signature, input)
|
|
41
|
+
errors = validate_input(signature, input)
|
|
42
|
+
raise ValidationError, errors.join(", ") unless errors.empty?
|
|
43
|
+
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def assert_valid_output!(signature, output)
|
|
48
|
+
errors = validate_output(signature, output)
|
|
49
|
+
raise ValidationError, errors.join(", ") unless errors.empty?
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def name_for(signature)
|
|
55
|
+
name = signature.name if signature.respond_to?(:name)
|
|
56
|
+
return name unless name.to_s.empty?
|
|
57
|
+
|
|
58
|
+
signature.to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_fields!(signature, method_name)
|
|
62
|
+
fields = signature.public_send(method_name)
|
|
63
|
+
return if fields.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
raise ConfigurationError, "signature .#{method_name} must return a Hash"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_payload(signature, payload, method_name)
|
|
69
|
+
validate_interface!(signature)
|
|
70
|
+
errors = signature.public_send(method_name, payload)
|
|
71
|
+
return errors if errors.is_a?(Array)
|
|
72
|
+
|
|
73
|
+
raise ConfigurationError, "signature .#{method_name} must return an Array"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|