dexkit 0.9.0 → 0.11.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +63 -254
  4. data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
  5. data/guides/llm/EVENT.md +25 -26
  6. data/guides/llm/FORM.md +200 -59
  7. data/guides/llm/OPERATION.md +115 -57
  8. data/guides/llm/QUERY.md +56 -0
  9. data/guides/llm/TOOL.md +308 -0
  10. data/lib/dex/context_dsl.rb +56 -0
  11. data/lib/dex/context_setup.rb +2 -33
  12. data/lib/dex/event/bus.rb +79 -11
  13. data/lib/dex/event/handler.rb +18 -1
  14. data/lib/dex/event/metadata.rb +15 -20
  15. data/lib/dex/event/processor.rb +2 -16
  16. data/lib/dex/event/test_helpers.rb +1 -1
  17. data/lib/dex/event.rb +3 -10
  18. data/lib/dex/form/context.rb +27 -0
  19. data/lib/dex/form/export.rb +128 -0
  20. data/lib/dex/form/nesting.rb +2 -0
  21. data/lib/dex/form.rb +119 -3
  22. data/lib/dex/id.rb +125 -0
  23. data/lib/dex/operation/async_proxy.rb +22 -4
  24. data/lib/dex/operation/guard_wrapper.rb +1 -1
  25. data/lib/dex/operation/jobs.rb +5 -4
  26. data/lib/dex/operation/once_wrapper.rb +1 -0
  27. data/lib/dex/operation/outcome.rb +14 -0
  28. data/lib/dex/operation/record_backend.rb +2 -1
  29. data/lib/dex/operation/record_wrapper.rb +14 -4
  30. data/lib/dex/operation/result_wrapper.rb +0 -12
  31. data/lib/dex/operation/test_helpers/assertions.rb +0 -88
  32. data/lib/dex/operation/test_helpers.rb +11 -1
  33. data/lib/dex/operation/ticket.rb +268 -0
  34. data/lib/dex/operation/trace_wrapper.rb +20 -0
  35. data/lib/dex/operation.rb +3 -0
  36. data/lib/dex/operation_failed.rb +14 -0
  37. data/lib/dex/query/export.rb +64 -0
  38. data/lib/dex/query.rb +41 -0
  39. data/lib/dex/test_log.rb +62 -4
  40. data/lib/dex/timeout.rb +14 -0
  41. data/lib/dex/tool.rb +388 -5
  42. data/lib/dex/trace.rb +291 -0
  43. data/lib/dex/version.rb +1 -1
  44. data/lib/dexkit.rb +22 -3
  45. metadata +12 -3
  46. data/lib/dex/event/trace.rb +0 -56
  47. data/lib/dex/event_test_helpers.rb +0 -3
data/lib/dex/query.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "active_model"
4
4
 
5
5
  require_relative "query/backend"
6
+ require_relative "query/export"
6
7
  require_relative "query/filtering"
7
8
  require_relative "query/sorting"
8
9
 
@@ -17,7 +18,11 @@ module Dex
17
18
  extend ActiveModel::Naming
18
19
  include ActiveModel::Conversion
19
20
 
21
+ extend Registry
22
+
20
23
  class << self
24
+ include ContextDSL
25
+
21
26
  def scope(&block)
22
27
  raise ArgumentError, "scope requires a block." unless block
23
28
 
@@ -31,6 +36,14 @@ module Dex
31
36
  end
32
37
 
33
38
  def new(scope: nil, sort: nil, **kwargs)
39
+ mappings = context_mappings
40
+ unless mappings.empty?
41
+ ambient = Dex.context
42
+ mappings.each do |prop_name, context_key|
43
+ next if kwargs.key?(prop_name)
44
+ kwargs[prop_name] = ambient[context_key] if ambient.key?(context_key)
45
+ end
46
+ end
34
47
  instance = super(**kwargs)
35
48
  instance.instance_variable_set(:@_injected_scope, scope)
36
49
  sort_str = sort&.to_s
@@ -96,6 +109,30 @@ module Dex
96
109
  subclass.instance_variable_set(:@_sort_default, _sort_default) if _sort_default
97
110
  end
98
111
 
112
+ # Export
113
+
114
+ def to_h
115
+ Export.build_hash(self)
116
+ end
117
+
118
+ def to_json_schema
119
+ Export.build_json_schema(self)
120
+ end
121
+
122
+ def export(format: :hash)
123
+ unless %i[hash json_schema].include?(format)
124
+ raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
125
+ end
126
+
127
+ sorted = registry.sort_by(&:name)
128
+ sorted.map do |klass|
129
+ case format
130
+ when :hash then klass.to_h
131
+ when :json_schema then klass.to_json_schema
132
+ end
133
+ end
134
+ end
135
+
99
136
  def from_params(params, scope: nil, **overrides)
100
137
  pk = model_name.param_key
101
138
  nested = _extract_nested_params(params, pk)
@@ -139,6 +176,10 @@ module Dex
139
176
 
140
177
  private
141
178
 
179
+ def _context_prop_declared?(name)
180
+ respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
181
+ end
182
+
142
183
  def _extract_nested_params(params, pk)
143
184
  hash = if params.respond_to?(:to_unsafe_h)
144
185
  params.to_unsafe_h
data/lib/dex/test_log.rb CHANGED
@@ -2,7 +2,18 @@
2
2
 
3
3
  module Dex
4
4
  module TestLog
5
- Entry = Data.define(:type, :name, :operation_class, :params, :result, :duration, :caller_location)
5
+ Entry = Data.define(
6
+ :type,
7
+ :name,
8
+ :operation_class,
9
+ :params,
10
+ :result,
11
+ :duration,
12
+ :caller_location,
13
+ :execution_id,
14
+ :trace_id,
15
+ :trace
16
+ )
6
17
 
7
18
  @_entries = []
8
19
  @_mutex = Mutex.new
@@ -42,14 +53,61 @@ module Dex
42
53
  return "No operations called." if entries.empty?
43
54
 
44
55
  lines = ["Operations called (#{entries.size}):"]
45
- entries.each_with_index do |entry, i|
56
+ nodes = entries.each_with_index.map { |entry, index| { entry: entry, index: index } }
57
+ by_id = nodes.to_h { |node| [node[:entry].execution_id, node] }
58
+ children = Hash.new { |hash, key| hash[key] = [] }
59
+ roots = []
60
+
61
+ nodes.each do |node|
62
+ parent_id = _parent_operation_id(node[:entry])
63
+ if parent_id && by_id[parent_id]
64
+ children[parent_id] << node
65
+ else
66
+ roots << node
67
+ end
68
+ end
69
+
70
+ counter = 0
71
+ render = lambda do |node, depth|
72
+ counter += 1
73
+ entry = node[:entry]
46
74
  status = entry.result.ok? ? "OK" : "ERR(#{entry.result.code})"
47
75
  duration_ms = entry.duration ? format("%.1fms", entry.duration * 1000) : "n/a"
48
- lines << " #{i + 1}. #{entry.name} [#{status}] #{duration_ms}"
49
- lines << " params: #{entry.params.inspect}" unless entry.params.nil? || entry.params.empty?
76
+ indent = " " * depth
77
+ id = entry.execution_id ? " (#{_display_id(entry.execution_id)})" : ""
78
+
79
+ lines << " #{indent}#{counter}. #{entry.name}#{id} [#{status}] #{duration_ms}"
80
+ lines << " #{indent} params: #{entry.params.inspect}" unless entry.params.nil? || entry.params.empty?
81
+
82
+ children.fetch(entry.execution_id, []).sort_by { |child| child[:index] }.each do |child|
83
+ render.call(child, depth + 1)
84
+ end
50
85
  end
86
+
87
+ roots.sort_by { |node| node[:index] }.each { |node| render.call(node, 0) }
51
88
  lines.join("\n")
52
89
  end
90
+
91
+ private
92
+
93
+ def _frame_type(frame)
94
+ return unless frame
95
+
96
+ (frame[:type] || frame["type"])&.to_sym
97
+ end
98
+
99
+ def _display_id(id)
100
+ prefix, suffix = id.to_s.split("_", 2)
101
+ return id.to_s unless suffix
102
+
103
+ "#{prefix}_#{suffix[0, 7]}"
104
+ end
105
+
106
+ def _parent_operation_id(entry)
107
+ frames = Array(entry.trace)[0...-1]
108
+ parent = frames.reverse.find { |frame| _frame_type(frame) == :operation }
109
+ parent && (parent[:id] || parent["id"])
110
+ end
53
111
  end
54
112
  end
55
113
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Timeout < StandardError
5
+ attr_reader :timeout, :ticket_id, :operation_name
6
+
7
+ def initialize(timeout:, ticket_id:, operation_name:)
8
+ @timeout = timeout.to_f
9
+ @ticket_id = ticket_id
10
+ @operation_name = operation_name
11
+ super("#{operation_name} did not complete within #{@timeout}s (ticket: #{ticket_id})")
12
+ end
13
+ end
14
+ end
data/lib/dex/tool.rb CHANGED
@@ -2,11 +2,22 @@
2
2
 
3
3
  module Dex
4
4
  module Tool
5
+ QUERY_TOOL_OPTIONS = %i[scope serialize limit only_filters except_filters only_sorts].freeze
6
+
5
7
  module_function
6
8
 
7
- def from(operation_class)
9
+ def from(klass, **opts)
8
10
  _require_ruby_llm!
9
- _build_tool(operation_class)
11
+
12
+ if klass < Dex::Query
13
+ _validate_query_tool_options!(klass, opts)
14
+ _build_query_tool(klass, **opts)
15
+ elsif klass < Dex::Operation
16
+ _reject_unknown_options!(klass, opts)
17
+ _build_tool(klass)
18
+ else
19
+ raise ArgumentError, "expected a Dex::Operation or Dex::Query subclass, got #{klass}"
20
+ end
10
21
  end
11
22
 
12
23
  def all
@@ -35,7 +46,9 @@ module Dex
35
46
  "Dex::Tool requires the ruby-llm gem. Add `gem 'ruby_llm'` to your Gemfile."
36
47
  end
37
48
 
38
- def _build_tool(operation_class) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
49
+ # --- Operation tools ---
50
+
51
+ def _build_tool(operation_class)
39
52
  op = operation_class
40
53
  schema = op.contract.to_json_schema
41
54
  tool_description = _tool_description(op)
@@ -77,7 +90,374 @@ module Dex
77
90
  parts.join("\n")
78
91
  end
79
92
 
80
- def _build_explain_tool # rubocop:disable Metrics/MethodLength
93
+ def _reject_unknown_options!(_klass, opts)
94
+ return if opts.empty?
95
+
96
+ raise ArgumentError, "#{opts.keys.first}: is not a valid option for Operation tools"
97
+ end
98
+
99
+ # --- Query tools ---
100
+
101
+ def _validate_query_tool_options!(klass, opts)
102
+ unknown = opts.keys - QUERY_TOOL_OPTIONS
103
+ unless unknown.empty?
104
+ raise ArgumentError, "unknown option#{"s" if unknown.size > 1}: #{unknown.map { |k| "#{k}:" }.join(", ")}"
105
+ end
106
+
107
+ label = klass.name || klass.inspect
108
+
109
+ unless opts.key?(:scope)
110
+ raise ArgumentError, "#{label} is a Dex::Query. Query tools require scope: and serialize:.\n" \
111
+ "Example: Dex::Tool.from(#{label},\n" \
112
+ " scope: -> { Current.user.orders },\n" \
113
+ " serialize: ->(record) { record.as_json(only: [:id, :name]) })"
114
+ end
115
+
116
+ unless opts.key?(:serialize)
117
+ raise ArgumentError, "#{label} is a Dex::Query. Query tools require serialize:.\n" \
118
+ "Example: Dex::Tool.from(#{label},\n" \
119
+ " scope: -> { Current.user.orders },\n" \
120
+ " serialize: ->(record) { record.as_json(only: [:id, :name]) })"
121
+ end
122
+
123
+ unless klass._scope_block
124
+ raise ArgumentError, "#{label} has no scope block. Define scope { ... } in the query class."
125
+ end
126
+
127
+ unless opts[:scope].respond_to?(:call)
128
+ raise ArgumentError, "scope: must respond to call (lambda or proc)"
129
+ end
130
+
131
+ unless opts[:serialize].respond_to?(:call)
132
+ raise ArgumentError, "serialize: must respond to call (lambda or proc)"
133
+ end
134
+
135
+ if opts.key?(:limit)
136
+ unless opts[:limit].is_a?(Integer) && opts[:limit] > 0
137
+ raise ArgumentError, "limit: must be a positive integer"
138
+ end
139
+ end
140
+
141
+ if opts.key?(:only_filters) && opts.key?(:except_filters)
142
+ raise ArgumentError, "only_filters: and except_filters: are mutually exclusive"
143
+ end
144
+
145
+ declared_filters = klass.filters
146
+ if opts.key?(:only_filters)
147
+ context_mapped = klass.context_mappings.keys.to_set
148
+ opts[:only_filters].each do |f|
149
+ f_sym = f.to_sym
150
+ unless declared_filters.include?(f_sym)
151
+ raise ArgumentError, "unknown filter :#{f}. Declared: #{declared_filters.inspect}"
152
+ end
153
+
154
+ if context_mapped.include?(f_sym)
155
+ raise ArgumentError, "filter :#{f} is context-mapped and automatically excluded from " \
156
+ "tool schema. Remove it from only_filters:"
157
+ end
158
+
159
+ prop = klass.literal_properties.find { |p| p.name == f_sym }
160
+ if prop && _ref_type?(prop.type)
161
+ raise ArgumentError, "filter :#{f} backs a _Ref prop and is automatically excluded from " \
162
+ "tool schema. Remove it from only_filters:"
163
+ end
164
+ end
165
+ end
166
+
167
+ if opts.key?(:except_filters)
168
+ opts[:except_filters].each do |f|
169
+ unless declared_filters.include?(f.to_sym)
170
+ raise ArgumentError, "unknown filter :#{f} in except_filters. Declared: #{declared_filters.inspect}"
171
+ end
172
+ end
173
+ end
174
+
175
+ declared_sorts = klass.sorts
176
+ if opts.key?(:only_sorts)
177
+ opts[:only_sorts].each do |s|
178
+ unless declared_sorts.include?(s.to_sym)
179
+ raise ArgumentError, "unknown sort :#{s}. Declared: #{declared_sorts.inspect}"
180
+ end
181
+ end
182
+
183
+ default_sort = klass._sort_default
184
+ if default_sort
185
+ bare = default_sort.delete_prefix("-").to_sym
186
+ unless opts[:only_sorts].map(&:to_sym).include?(bare)
187
+ raise ArgumentError, "query default sort #{default_sort} is not in only_sorts. " \
188
+ "Include it or change the query's default"
189
+ end
190
+ end
191
+ end
192
+
193
+ if klass.respond_to?(:literal_properties)
194
+ klass.literal_properties.each do |prop|
195
+ if %i[limit offset].include?(prop.name)
196
+ raise ArgumentError, "#{label} declares prop :#{prop.name} which conflicts with " \
197
+ "the tool's pagination parameter. Rename the prop"
198
+ end
199
+ end
200
+ end
201
+
202
+ _validate_satisfiable_props!(klass, opts)
203
+ end
204
+
205
+ def _validate_satisfiable_props!(klass, opts)
206
+ return unless klass.respond_to?(:literal_properties)
207
+
208
+ context_keys = klass.context_mappings.keys.to_set
209
+ visible = _compute_visible_filters(klass, opts[:only_filters], opts[:except_filters])
210
+ excluded = _query_excluded_prop_names(klass, visible)
211
+
212
+ klass.literal_properties.each do |prop|
213
+ next unless excluded.include?(prop.name)
214
+ next unless prop.required?
215
+ next if context_keys.include?(prop.name)
216
+
217
+ if _ref_type?(prop.type)
218
+ raise ArgumentError, "prop :#{prop.name} (_Ref) is auto-excluded from the tool schema " \
219
+ "but is required with no default and no context mapping — the tool could never execute"
220
+ else
221
+ raise ArgumentError, "excluding filter :#{prop.name} hides required prop :#{prop.name} " \
222
+ "which has no default and no context mapping — the tool could never execute"
223
+ end
224
+ end
225
+ end
226
+
227
+ def _build_query_tool(klass, scope:, serialize:, limit: 50, only_filters: nil, except_filters: nil, only_sorts: nil)
228
+ max_limit = limit
229
+ scope_lambda = scope
230
+ serialize_lambda = serialize
231
+
232
+ visible_filters = _compute_visible_filters(klass, only_filters, except_filters)
233
+ allowed_sorts = only_sorts ? only_sorts.map(&:to_sym) : klass.sorts
234
+
235
+ schema = _query_params_schema(klass, visible_filters, allowed_sorts, max_limit)
236
+ tool_desc = _query_tool_description(klass, visible_filters, allowed_sorts, max_limit)
237
+ tool_name = "dex_query_#{(klass.name || "query").gsub("::", "_").downcase}"
238
+
239
+ stripped_param_keys = klass.context_mappings.keys.to_set
240
+ invisible_filters = klass.filters.to_set - visible_filters.to_set
241
+ stripped_param_keys.merge(invisible_filters)
242
+
243
+ query_class = klass
244
+
245
+ Class.new(RubyLLM::Tool) do
246
+ define_method(:name) { tool_name }
247
+ define_method(:description) { tool_desc }
248
+ define_method(:params_schema) { schema }
249
+
250
+ define_method(:execute) do |**params|
251
+ params = params.transform_keys(&:to_sym)
252
+
253
+ req_limit = [(params.delete(:limit) || max_limit).to_i, max_limit].min
254
+ req_limit = max_limit if req_limit <= 0
255
+ req_offset = [params.delete(:offset).to_i, 0].max
256
+
257
+ sort_value = params.delete(:sort)&.to_s
258
+ if sort_value && !sort_value.empty?
259
+ bare = sort_value.delete_prefix("-").to_sym
260
+ valid = allowed_sorts.include?(bare)
261
+ if valid && sort_value.start_with?("-")
262
+ sort_def = query_class._sort_registry[bare]
263
+ valid = false if sort_def&.custom
264
+ end
265
+ sort_value = nil unless valid
266
+ else
267
+ sort_value = nil
268
+ end
269
+
270
+ stripped_param_keys.each { |k| params.delete(k) }
271
+
272
+ injected_scope = scope_lambda.call
273
+
274
+ overrides = {}
275
+ overrides[:sort] = sort_value if sort_value
276
+ query = query_class.from_params(params, scope: injected_scope, **overrides)
277
+
278
+ records = query.resolve
279
+
280
+ total = begin
281
+ result = records.count
282
+ result.is_a?(Integer) ? result : nil
283
+ rescue
284
+ nil
285
+ end
286
+
287
+ records = records.offset(req_offset).limit(req_limit)
288
+
289
+ serialized = records.map { |r| serialize_lambda.call(r) }
290
+
291
+ { records: serialized, total: total, limit: req_limit, offset: req_offset }
292
+ rescue ArgumentError, Literal::TypeError => e
293
+ { error: "invalid_params", message: e.message }
294
+ rescue => e
295
+ { error: "query_failed", message: e.message }
296
+ end
297
+ end.new
298
+ end
299
+
300
+ def _query_tool_description(klass, visible_filters, allowed_sorts, max_limit)
301
+ parts = []
302
+ text = klass.description || klass.name || "Query"
303
+ parts << (text.end_with?(".") ? text : "#{text}.")
304
+
305
+ if visible_filters.any?
306
+ filter_descs = visible_filters.map { |f| _query_filter_desc(klass, f) }
307
+ parts << "Filters: #{filter_descs.join(", ")}."
308
+ end
309
+
310
+ if allowed_sorts.any?
311
+ default_sort = klass._sort_default
312
+ sort_descs = allowed_sorts.map do |s|
313
+ bare_default = default_sort&.delete_prefix("-")
314
+ if bare_default && bare_default.to_sym == s
315
+ "#{s} (default: #{default_sort})"
316
+ else
317
+ s.to_s
318
+ end
319
+ end
320
+ parts << "Sorts: #{sort_descs.join(", ")}."
321
+ end
322
+
323
+ parts << "Returns up to #{max_limit} results per page. Use offset to paginate."
324
+
325
+ parts.join("\n")
326
+ end
327
+
328
+ def _query_filter_desc(klass, filter_name)
329
+ descs = klass.respond_to?(:prop_descriptions) ? klass.prop_descriptions : {}
330
+ prop_desc = descs[filter_name]
331
+
332
+ prop = klass.literal_properties.find { |p| p.name == filter_name }
333
+ return filter_name.to_s unless prop
334
+
335
+ inner_type = prop.type
336
+ inner_type = inner_type.type if inner_type.is_a?(Literal::Types::NilableType)
337
+
338
+ if inner_type.respond_to?(:primitives) && inner_type.primitives&.any?
339
+ values = inner_type.primitives.to_a.map(&:to_s)
340
+ "#{filter_name} (#{_join_with_or(values)})"
341
+ elsif prop_desc
342
+ "#{filter_name} (#{prop_desc})"
343
+ else
344
+ filter_name.to_s
345
+ end
346
+ end
347
+
348
+ def _join_with_or(values)
349
+ case values.size
350
+ when 1 then values.first
351
+ when 2 then "#{values.first} or #{values.last}"
352
+ else "#{values[..-2].join(", ")}, or #{values.last}"
353
+ end
354
+ end
355
+
356
+ def _query_params_schema(klass, visible_filters, allowed_sorts, max_limit)
357
+ properties = {}
358
+ required = []
359
+
360
+ excluded = _query_excluded_prop_names(klass, visible_filters)
361
+ descs = klass.respond_to?(:prop_descriptions) ? klass.prop_descriptions : {}
362
+
363
+ if klass.respond_to?(:literal_properties)
364
+ klass.literal_properties.each do |prop|
365
+ next if excluded.include?(prop.name)
366
+
367
+ prop_desc = descs[prop.name]
368
+ schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
369
+ properties[prop.name.to_s] = schema
370
+ required << prop.name.to_s if prop.required?
371
+ end
372
+ end
373
+
374
+ if allowed_sorts.any?
375
+ sort_values = []
376
+ allowed_sorts.each do |s|
377
+ sort_def = klass._sort_registry[s]
378
+ sort_values << s.to_s
379
+ sort_values << "-#{s}" unless sort_def.custom
380
+ end
381
+
382
+ default_sort = klass._sort_default
383
+ sort_desc = "Sort order. Prefix with - for descending."
384
+ sort_desc += " Default: #{default_sort}" if default_sort
385
+
386
+ properties["sort"] = {
387
+ type: "string",
388
+ enum: sort_values,
389
+ description: sort_desc
390
+ }
391
+ end
392
+
393
+ properties["limit"] = {
394
+ type: "integer",
395
+ description: "Maximum number of results (default: #{max_limit}, max: #{max_limit})"
396
+ }
397
+ properties["offset"] = {
398
+ type: "integer",
399
+ description: "Number of results to skip (default: 0)"
400
+ }
401
+
402
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
403
+ result[:type] = "object"
404
+ result[:properties] = properties
405
+ result[:required] = required unless required.empty?
406
+ result[:additionalProperties] = false
407
+ result
408
+ end
409
+
410
+ def _compute_visible_filters(klass, only_filters, except_filters)
411
+ context_keys = klass.context_mappings.keys.to_set
412
+
413
+ filters = klass.filters.dup
414
+ filters.reject! { |f| context_keys.include?(f) }
415
+ filters.reject! do |f|
416
+ prop = klass.literal_properties.find { |p| p.name == f }
417
+ prop && _ref_type?(prop.type)
418
+ end
419
+
420
+ if only_filters
421
+ only_set = only_filters.map(&:to_sym).to_set
422
+ filters.select! { |f| only_set.include?(f) }
423
+ elsif except_filters
424
+ except_set = except_filters.map(&:to_sym).to_set
425
+ filters.reject! { |f| except_set.include?(f) }
426
+ end
427
+
428
+ filters
429
+ end
430
+
431
+ def _query_excluded_prop_names(klass, visible_filters)
432
+ return Set.new unless klass.respond_to?(:literal_properties)
433
+
434
+ context_keys = klass.context_mappings.keys.to_set
435
+ visible_set = visible_filters.to_set
436
+ all_filters = klass.filters.to_set
437
+
438
+ excluded = Set.new
439
+ excluded.merge(context_keys)
440
+
441
+ klass.literal_properties.each do |prop|
442
+ excluded << prop.name if _ref_type?(prop.type)
443
+ end
444
+
445
+ invisible_filters = all_filters - visible_set
446
+ excluded.merge(invisible_filters)
447
+
448
+ excluded
449
+ end
450
+
451
+ def _ref_type?(type)
452
+ return true if type.is_a?(Dex::RefType)
453
+ return _ref_type?(type.type) if type.respond_to?(:type)
454
+
455
+ false
456
+ end
457
+
458
+ # --- Explain tool ---
459
+
460
+ def _build_explain_tool
81
461
  Class.new(RubyLLM::Tool) do
82
462
  define_method(:name) { "dex_explain" }
83
463
  define_method(:description) { "Check if an operation can be executed with given params, without running it." }
@@ -110,6 +490,9 @@ module Dex
110
490
  end.new
111
491
  end
112
492
 
113
- private_class_method :_require_ruby_llm!, :_build_tool, :_tool_description, :_build_explain_tool
493
+ private_class_method :_require_ruby_llm!, :_build_tool, :_tool_description, :_build_explain_tool,
494
+ :_reject_unknown_options!, :_validate_query_tool_options!, :_validate_satisfiable_props!,
495
+ :_build_query_tool, :_query_tool_description, :_query_filter_desc, :_join_with_or,
496
+ :_query_params_schema, :_compute_visible_filters, :_query_excluded_prop_names, :_ref_type?
114
497
  end
115
498
  end