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.
@@ -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
data/lib/rlm/trace.rb CHANGED
@@ -15,6 +15,8 @@ module RLM
15
15
  file_read
16
16
  tool_called
17
17
  sub_lm_called
18
+ output_submitted
19
+ runtime_logged
18
20
  validation_attempted
19
21
  validation_failed
20
22
  budget_checked