dexkit 0.10.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.
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
data/lib/dex/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dex
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/dexkit.rb CHANGED
@@ -13,6 +13,8 @@ require_relative "dex/props_setup"
13
13
  require_relative "dex/context_dsl"
14
14
  require_relative "dex/context_setup"
15
15
  require_relative "dex/error"
16
+ require_relative "dex/operation_failed"
17
+ require_relative "dex/timeout"
16
18
  require_relative "dex/settings"
17
19
  require_relative "dex/pipeline"
18
20
  require_relative "dex/executable"
@@ -28,15 +30,13 @@ require_relative "dex/query"
28
30
 
29
31
  module Dex
30
32
  class Configuration
31
- attr_accessor :record_class, :event_store, :event_context, :restore_event_context
33
+ attr_accessor :record_class, :event_store
32
34
  attr_reader :transaction_adapter
33
35
 
34
36
  def initialize
35
37
  @record_class = nil
36
38
  @transaction_adapter = nil
37
39
  @event_store = nil
38
- @event_context = nil
39
- @restore_event_context = nil
40
40
  end
41
41
 
42
42
  def transaction_adapter=(adapter)
@@ -80,6 +80,22 @@ module Dex
80
80
  configuration.transaction_adapter = adapter
81
81
  end
82
82
 
83
+ def actor
84
+ frame = Dex::Trace.actor
85
+ return nil unless frame
86
+
87
+ payload = frame.dup
88
+ payload.delete(:type)
89
+ payload[:type] = payload.delete(:actor_type) if payload.key?(:actor_type)
90
+ payload
91
+ end
92
+
93
+ def system(name = nil)
94
+ h = { type: :system }
95
+ h[:name] = name.to_s if name
96
+ h.freeze
97
+ end
98
+
83
99
  CONTEXT_KEY = :_dex_context
84
100
  EMPTY_CONTEXT = {}.freeze
85
101
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dexkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacek Galanciak
@@ -167,6 +167,7 @@ files:
167
167
  - guides/llm/FORM.md
168
168
  - guides/llm/OPERATION.md
169
169
  - guides/llm/QUERY.md
170
+ - guides/llm/TOOL.md
170
171
  - lib/dex/concern.rb
171
172
  - lib/dex/context_dsl.rb
172
173
  - lib/dex/context_setup.rb
@@ -181,8 +182,6 @@ files:
181
182
  - lib/dex/event/suppression.rb
182
183
  - lib/dex/event/test_helpers.rb
183
184
  - lib/dex/event/test_helpers/assertions.rb
184
- - lib/dex/event/trace.rb
185
- - lib/dex/event_test_helpers.rb
186
185
  - lib/dex/executable.rb
187
186
  - lib/dex/form.rb
188
187
  - lib/dex/form/context.rb
@@ -211,9 +210,11 @@ files:
211
210
  - lib/dex/operation/test_helpers/assertions.rb
212
211
  - lib/dex/operation/test_helpers/execution.rb
213
212
  - lib/dex/operation/test_helpers/stubbing.rb
213
+ - lib/dex/operation/ticket.rb
214
214
  - lib/dex/operation/trace_wrapper.rb
215
215
  - lib/dex/operation/transaction_adapter.rb
216
216
  - lib/dex/operation/transaction_wrapper.rb
217
+ - lib/dex/operation_failed.rb
217
218
  - lib/dex/pipeline.rb
218
219
  - lib/dex/props_setup.rb
219
220
  - lib/dex/query.rb
@@ -227,6 +228,7 @@ files:
227
228
  - lib/dex/settings.rb
228
229
  - lib/dex/test_helpers.rb
229
230
  - lib/dex/test_log.rb
231
+ - lib/dex/timeout.rb
230
232
  - lib/dex/tool.rb
231
233
  - lib/dex/trace.rb
232
234
  - lib/dex/type_coercion.rb
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Dex
4
- class Event
5
- module Trace
6
- class << self
7
- def with_event(event, &block)
8
- Dex::Trace.with_event_context(event, &block)
9
- end
10
-
11
- def current_event_id
12
- Dex::Trace.current_event_id
13
- end
14
-
15
- def current_trace_id
16
- Dex::Trace.trace_id
17
- end
18
-
19
- def dump
20
- Dex::Trace.dump
21
- end
22
-
23
- def restore(data, &block)
24
- return yield unless data
25
-
26
- if data.is_a?(Hash) && (data.key?(:frames) || data.key?("frames"))
27
- Dex::Trace.restore(data, &block)
28
- else
29
- Dex::Trace.restore_event_context(
30
- event_id: data[:id] || data["id"],
31
- trace_id: data[:trace_id] || data["trace_id"],
32
- &block
33
- )
34
- end
35
- end
36
-
37
- def clear!
38
- Dex::Trace.clear!
39
- end
40
- end
41
- end
42
- end
43
- end
@@ -1,3 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "event/test_helpers"