dexkit 0.7.0 → 0.8.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 +31 -0
- data/README.md +34 -5
- data/guides/llm/EVENT.md +43 -1
- data/guides/llm/FORM.md +1 -1
- data/guides/llm/OPERATION.md +106 -2
- data/guides/llm/QUERY.md +1 -1
- data/lib/dex/event/export.rb +56 -0
- data/lib/dex/event/handler.rb +33 -0
- data/lib/dex/event.rb +27 -0
- data/lib/dex/operation/explain.rb +204 -0
- data/lib/dex/operation/export.rb +144 -0
- data/lib/dex/operation/guard_wrapper.rb +15 -4
- data/lib/dex/operation/record_backend.rb +12 -0
- data/lib/dex/operation.rb +46 -2
- data/lib/dex/props_setup.rb +25 -2
- data/lib/dex/railtie.rb +84 -0
- data/lib/dex/registry.rb +63 -0
- data/lib/dex/tool.rb +115 -0
- data/lib/dex/type_serializer.rb +132 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +5 -0
- metadata +8 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_hash(source, contract) # rubocop:disable Metrics/MethodLength
|
|
9
|
+
h = {}
|
|
10
|
+
h[:name] = source.name if source&.name
|
|
11
|
+
desc = source&.description
|
|
12
|
+
h[:description] = desc if desc
|
|
13
|
+
h[:params] = _serialize_params(source, contract.params)
|
|
14
|
+
h[:success] = TypeSerializer.to_string(contract.success) if contract.success
|
|
15
|
+
h[:errors] = contract.errors unless contract.errors.empty?
|
|
16
|
+
h[:guards] = contract.guards unless contract.guards.empty?
|
|
17
|
+
ctx = _serialize_context(source)
|
|
18
|
+
h[:context] = ctx unless ctx.empty?
|
|
19
|
+
h[:pipeline] = source.pipeline.steps.map(&:name) if source
|
|
20
|
+
h[:settings] = _serialize_settings(source) if source
|
|
21
|
+
h
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_json_schema(source, contract, section: :params) # rubocop:disable Metrics/MethodLength
|
|
25
|
+
case section
|
|
26
|
+
when :params then _params_schema(source, contract)
|
|
27
|
+
when :success then _success_schema(source, contract)
|
|
28
|
+
when :errors then _errors_schema(source, contract)
|
|
29
|
+
when :full then _full_schema(source, contract)
|
|
30
|
+
else
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"unknown section: #{section.inspect}. Known: :params, :success, :errors, :full"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def _serialize_params(source, params)
|
|
37
|
+
descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
38
|
+
params.each_with_object({}) do |(name, type), hash|
|
|
39
|
+
entry = { type: TypeSerializer.to_string(type), required: _required?(source, name) }
|
|
40
|
+
entry[:desc] = descs[name] if descs[name]
|
|
41
|
+
hash[name] = entry
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def _required?(source, prop_name)
|
|
46
|
+
return true unless source&.respond_to?(:literal_properties)
|
|
47
|
+
|
|
48
|
+
prop = source.literal_properties.find { |p| p.name == prop_name }
|
|
49
|
+
return true unless prop
|
|
50
|
+
|
|
51
|
+
prop.required?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def _serialize_context(source)
|
|
55
|
+
source&.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def _serialize_settings(source) # rubocop:disable Metrics/MethodLength
|
|
59
|
+
settings = {}
|
|
60
|
+
|
|
61
|
+
record_s = source.settings_for(:record)
|
|
62
|
+
settings[:record] = {
|
|
63
|
+
enabled: record_s.fetch(:enabled, true),
|
|
64
|
+
params: record_s.fetch(:params, true),
|
|
65
|
+
result: record_s.fetch(:result, true)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
tx_s = source.settings_for(:transaction)
|
|
69
|
+
settings[:transaction] = { enabled: tx_s.fetch(:enabled, true) }
|
|
70
|
+
|
|
71
|
+
once_s = source.settings_for(:once)
|
|
72
|
+
settings[:once] = { defined: once_s.fetch(:defined, false) }
|
|
73
|
+
|
|
74
|
+
settings
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def _params_schema(source, contract) # rubocop:disable Metrics/MethodLength
|
|
78
|
+
descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
79
|
+
properties = {}
|
|
80
|
+
required = []
|
|
81
|
+
|
|
82
|
+
contract.params.each do |name, type|
|
|
83
|
+
prop_desc = descs[name]
|
|
84
|
+
schema = TypeSerializer.to_json_schema(type, desc: prop_desc)
|
|
85
|
+
properties[name.to_s] = schema
|
|
86
|
+
required << name.to_s if _required?(source, name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
|
|
90
|
+
result[:title] = source.name if source&.name
|
|
91
|
+
desc = source&.description
|
|
92
|
+
result[:description] = desc if desc
|
|
93
|
+
result[:properties] = properties unless properties.empty?
|
|
94
|
+
result[:required] = required unless required.empty?
|
|
95
|
+
result[:additionalProperties] = false
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def _success_schema(source, contract)
|
|
100
|
+
return {} unless contract.success
|
|
101
|
+
|
|
102
|
+
schema = TypeSerializer.to_json_schema(contract.success)
|
|
103
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
104
|
+
result[:title] = "#{source&.name} success" if source&.name
|
|
105
|
+
result.merge(schema)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def _errors_schema(source, contract)
|
|
109
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
110
|
+
result[:title] = "#{source&.name} errors" if source&.name
|
|
111
|
+
result[:type] = "object"
|
|
112
|
+
|
|
113
|
+
properties = {}
|
|
114
|
+
contract.errors.each do |code|
|
|
115
|
+
properties[code.to_s] = {
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: {
|
|
118
|
+
code: { const: code.to_s },
|
|
119
|
+
message: { type: "string" },
|
|
120
|
+
details: { type: "object" }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
result[:properties] = properties unless properties.empty?
|
|
125
|
+
result
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def _full_schema(source, contract)
|
|
129
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
130
|
+
result[:title] = source.name if source&.name
|
|
131
|
+
result[:description] = "Operation contract"
|
|
132
|
+
result[:properties] = {
|
|
133
|
+
params: _params_schema(source, contract).except(:$schema),
|
|
134
|
+
success: _success_schema(source, contract).except(:$schema),
|
|
135
|
+
errors: _errors_schema(source, contract).except(:$schema)
|
|
136
|
+
}
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private_class_method :_serialize_params, :_required?, :_serialize_context, :_serialize_settings,
|
|
141
|
+
:_params_schema, :_success_schema, :_errors_schema, :_full_schema
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -107,16 +107,17 @@ module Dex
|
|
|
107
107
|
|
|
108
108
|
private
|
|
109
109
|
|
|
110
|
-
def
|
|
110
|
+
def _guard_evaluate_all
|
|
111
111
|
guards = self.class._guard_list
|
|
112
112
|
return [] if guards.empty?
|
|
113
113
|
|
|
114
114
|
blocked_names = Set.new
|
|
115
|
-
|
|
115
|
+
results = []
|
|
116
116
|
|
|
117
117
|
guards.each do |guard|
|
|
118
118
|
if guard.requires.any? { |dep| blocked_names.include?(dep) }
|
|
119
119
|
blocked_names << guard.name
|
|
120
|
+
results << { name: guard.name, passed: false, skipped: true }
|
|
120
121
|
next
|
|
121
122
|
end
|
|
122
123
|
|
|
@@ -128,11 +129,21 @@ module Dex
|
|
|
128
129
|
|
|
129
130
|
if threat
|
|
130
131
|
blocked_names << guard.name
|
|
131
|
-
|
|
132
|
+
results << { name: guard.name, passed: false, message: guard.message || guard.name.to_s }
|
|
133
|
+
else
|
|
134
|
+
results << { name: guard.name, passed: true }
|
|
132
135
|
end
|
|
133
136
|
end
|
|
134
137
|
|
|
135
|
-
|
|
138
|
+
results
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def _guard_evaluate
|
|
142
|
+
_guard_evaluate_all.filter_map do |r|
|
|
143
|
+
next if r[:passed] || r[:skipped]
|
|
144
|
+
|
|
145
|
+
{ guard: r[:name], message: r[:message] }
|
|
146
|
+
end
|
|
136
147
|
end
|
|
137
148
|
end
|
|
138
149
|
end
|
|
@@ -42,6 +42,10 @@ module Dex
|
|
|
42
42
|
raise NotImplementedError
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
def find_pending_once_key(key)
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
45
49
|
def update_record_by_once_key(key, **attributes)
|
|
46
50
|
raise NotImplementedError
|
|
47
51
|
end
|
|
@@ -80,6 +84,10 @@ module Dex
|
|
|
80
84
|
.first
|
|
81
85
|
end
|
|
82
86
|
|
|
87
|
+
def find_pending_once_key(key)
|
|
88
|
+
record_class.where(once_key: key, status: %w[pending running]).first
|
|
89
|
+
end
|
|
90
|
+
|
|
83
91
|
def update_record_by_once_key(key, **attributes)
|
|
84
92
|
record = record_class.where(once_key: key, status: %w[completed error]).first
|
|
85
93
|
record&.update!(safe_attributes(attributes))
|
|
@@ -119,6 +127,10 @@ module Dex
|
|
|
119
127
|
).first
|
|
120
128
|
end
|
|
121
129
|
|
|
130
|
+
def find_pending_once_key(key)
|
|
131
|
+
record_class.where(:once_key => key, :status.in => %w[pending running]).first
|
|
132
|
+
end
|
|
133
|
+
|
|
122
134
|
def update_record_by_once_key(key, **attributes)
|
|
123
135
|
record = record_class.where(:once_key => key, :status.in => %w[completed error]).first
|
|
124
136
|
record&.update!(safe_attributes(attributes))
|
data/lib/dex/operation.rb
CHANGED
|
@@ -49,7 +49,32 @@ module Dex
|
|
|
49
49
|
include TypeCoercion
|
|
50
50
|
include ContextSetup
|
|
51
51
|
|
|
52
|
-
Contract = Data.define(:params, :success, :errors, :guards)
|
|
52
|
+
Contract = Data.define(:params, :success, :errors, :guards) do
|
|
53
|
+
attr_reader :source_class
|
|
54
|
+
|
|
55
|
+
def initialize(params:, success:, errors:, guards:, source_class: nil)
|
|
56
|
+
@source_class = source_class
|
|
57
|
+
super(params: params, success: success, errors: errors, guards: guards)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
if @source_class
|
|
62
|
+
Operation::Export.build_hash(@source_class, self)
|
|
63
|
+
else
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_json_schema(**options)
|
|
69
|
+
unless @source_class
|
|
70
|
+
raise ArgumentError, "to_json_schema requires a source_class (use OperationClass.contract.to_json_schema)"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Operation::Export.build_json_schema(@source_class, self, **options)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
extend Registry
|
|
53
78
|
|
|
54
79
|
class << self
|
|
55
80
|
def contract
|
|
@@ -57,10 +82,25 @@ module Dex
|
|
|
57
82
|
params: _contract_params,
|
|
58
83
|
success: _success_type,
|
|
59
84
|
errors: _declared_errors,
|
|
60
|
-
guards: _contract_guards
|
|
85
|
+
guards: _contract_guards,
|
|
86
|
+
source_class: self
|
|
61
87
|
)
|
|
62
88
|
end
|
|
63
89
|
|
|
90
|
+
def export(format: :hash, **options)
|
|
91
|
+
unless %i[hash json_schema].include?(format)
|
|
92
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sorted = registry.sort_by(&:name)
|
|
96
|
+
sorted.map do |klass|
|
|
97
|
+
case format
|
|
98
|
+
when :hash then klass.contract.to_h
|
|
99
|
+
when :json_schema then klass.contract.to_json_schema(**options)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
64
104
|
private
|
|
65
105
|
|
|
66
106
|
def _contract_params
|
|
@@ -116,6 +156,10 @@ require_relative "operation/async_proxy"
|
|
|
116
156
|
require_relative "operation/record_backend"
|
|
117
157
|
require_relative "operation/transaction_adapter"
|
|
118
158
|
require_relative "operation/jobs"
|
|
159
|
+
require_relative "operation/explain"
|
|
160
|
+
require_relative "operation/export"
|
|
161
|
+
|
|
162
|
+
Dex::Operation.extend(Dex::Operation::Explain)
|
|
119
163
|
|
|
120
164
|
# Top-level aliases (depend on Operation::Ok/Err)
|
|
121
165
|
require_relative "match"
|
data/lib/dex/props_setup.rb
CHANGED
|
@@ -17,10 +17,17 @@ module Dex
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
module ClassMethods
|
|
20
|
-
def prop(name, type, kind = :keyword, **options, &block)
|
|
20
|
+
def prop(name, type, kind = :keyword, desc: nil, **options, &block)
|
|
21
21
|
if const_defined?(:RESERVED_PROP_NAMES) && self::RESERVED_PROP_NAMES.include?(name)
|
|
22
22
|
raise ArgumentError, "Property :#{name} is reserved."
|
|
23
23
|
end
|
|
24
|
+
if !desc.nil?
|
|
25
|
+
raise ArgumentError, "desc: must be a String, got #{desc.class}" unless desc.is_a?(String)
|
|
26
|
+
|
|
27
|
+
_prop_desc_own[name] = desc
|
|
28
|
+
elsif superclass.respond_to?(:prop_descriptions) && superclass.prop_descriptions.key?(name)
|
|
29
|
+
_prop_desc_own[name] = nil
|
|
30
|
+
end
|
|
24
31
|
options[:reader] = :public unless options.key?(:reader)
|
|
25
32
|
if type.is_a?(Dex::RefType) && !block
|
|
26
33
|
ref = type
|
|
@@ -29,7 +36,12 @@ module Dex
|
|
|
29
36
|
super(name, type, kind, **options, &block)
|
|
30
37
|
end
|
|
31
38
|
|
|
32
|
-
def prop?(name, type, kind = :keyword, **options, &block)
|
|
39
|
+
def prop?(name, type, kind = :keyword, desc: nil, **options, &block)
|
|
40
|
+
if !desc.nil?
|
|
41
|
+
raise ArgumentError, "desc: must be a String, got #{desc.class}" unless desc.is_a?(String)
|
|
42
|
+
|
|
43
|
+
_prop_desc_own[name] = desc
|
|
44
|
+
end
|
|
33
45
|
options[:reader] = :public unless options.key?(:reader)
|
|
34
46
|
options[:default] = nil unless options.key?(:default)
|
|
35
47
|
if type.is_a?(Dex::RefType) && !block
|
|
@@ -39,9 +51,20 @@ module Dex
|
|
|
39
51
|
prop(name, _Nilable(type), kind, **options, &block)
|
|
40
52
|
end
|
|
41
53
|
|
|
54
|
+
def prop_descriptions
|
|
55
|
+
parent = superclass.respond_to?(:prop_descriptions) ? superclass.prop_descriptions : {}
|
|
56
|
+
parent.merge(_prop_desc_own).compact
|
|
57
|
+
end
|
|
58
|
+
|
|
42
59
|
def _Ref(model_class, lock: false) # rubocop:disable Naming/MethodName
|
|
43
60
|
Dex::RefType.new(model_class, lock: lock)
|
|
44
61
|
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def _prop_desc_own
|
|
66
|
+
@_prop_desc_own ||= {}
|
|
67
|
+
end
|
|
45
68
|
end
|
|
46
69
|
end
|
|
47
70
|
end
|
data/lib/dex/railtie.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
rake_tasks do
|
|
6
|
+
namespace :dex do
|
|
7
|
+
desc "Export operation/event/handler contracts (FORMAT=hash|json_schema SECTION=operations|events|handlers FILE=path)"
|
|
8
|
+
task export: :environment do
|
|
9
|
+
Rails.application.eager_load!
|
|
10
|
+
|
|
11
|
+
format = (ENV["FORMAT"] || "hash").to_sym
|
|
12
|
+
section = ENV["SECTION"] || "operations"
|
|
13
|
+
file = ENV["FILE"]
|
|
14
|
+
|
|
15
|
+
data = case section
|
|
16
|
+
when "operations" then Dex::Operation.export(format: format)
|
|
17
|
+
when "events" then Dex::Event.export(format: format)
|
|
18
|
+
when "handlers"
|
|
19
|
+
if format != :hash
|
|
20
|
+
raise "Handlers only support FORMAT=hash (got #{format})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Dex::Event::Handler.export(format: format)
|
|
24
|
+
else
|
|
25
|
+
raise "Unknown SECTION=#{section}. Known: operations, events, handlers"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
json = JSON.pretty_generate(data)
|
|
29
|
+
|
|
30
|
+
if file
|
|
31
|
+
File.write(file, json)
|
|
32
|
+
puts "Wrote #{data.size} #{section} to #{file}"
|
|
33
|
+
else
|
|
34
|
+
puts json
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "Install LLM guides as AGENTS.md in app directories (FORCE=1 to overwrite app-owned files)"
|
|
39
|
+
task :guides do
|
|
40
|
+
gem_root = File.expand_path("../..", __dir__)
|
|
41
|
+
guide_dir = File.join(gem_root, "guides", "llm")
|
|
42
|
+
marker = "<!-- dexkit v#{Dex::VERSION} | Auto-generated by: rake dex:guides -->"
|
|
43
|
+
force = ENV["FORCE"] == "1"
|
|
44
|
+
written = 0
|
|
45
|
+
|
|
46
|
+
# [source_file, env_var, default_path]
|
|
47
|
+
mapping = [
|
|
48
|
+
["OPERATION.md", "OPERATIONS_PATH", "app/operations"],
|
|
49
|
+
["EVENT.md", "EVENTS_PATH", "app/events"],
|
|
50
|
+
["EVENT.md", "EVENT_HANDLERS_PATH", "app/event_handlers"],
|
|
51
|
+
["FORM.md", "FORMS_PATH", "app/forms"],
|
|
52
|
+
["QUERY.md", "QUERIES_PATH", "app/queries"]
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
mapping.each do |source_file, env_var, default_path|
|
|
56
|
+
target_dir = ENV[env_var] || default_path
|
|
57
|
+
|
|
58
|
+
unless File.directory?(target_dir)
|
|
59
|
+
puts " #{target_dir}/AGENTS.md (skipped — directory not found)"
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
target = File.join(target_dir, "AGENTS.md")
|
|
64
|
+
|
|
65
|
+
if File.exist?(target) && !force
|
|
66
|
+
first_line = File.open(target, &:readline).chomp rescue "" # rubocop:disable Style/RescueModifier
|
|
67
|
+
unless first_line.start_with?("<!-- dexkit v")
|
|
68
|
+
puts " #{target} (skipped — not generated by dexkit, use FORCE=1 to overwrite)"
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
source = File.join(guide_dir, source_file)
|
|
74
|
+
File.write(target, "#{marker}\n\n#{File.read(source)}")
|
|
75
|
+
written += 1
|
|
76
|
+
puts " #{target} ← #{source_file}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
puts "\n#{written} guide(s) installed."
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/dex/registry.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module Registry
|
|
5
|
+
def self.extended(base)
|
|
6
|
+
base.instance_variable_set(:@_registry, Set.new)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def inherited(subclass)
|
|
10
|
+
super
|
|
11
|
+
_dex_registry.add(subclass)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def registry
|
|
15
|
+
live = _dex_registry.select { |k| k.name && _dex_reachable?(k) }
|
|
16
|
+
_dex_registry.replace(live)
|
|
17
|
+
live.to_set.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def deregister(klass)
|
|
21
|
+
_dex_registry.delete(klass)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear!
|
|
25
|
+
_dex_registry.clear
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def description(text = nil)
|
|
29
|
+
if !text.nil?
|
|
30
|
+
raise ArgumentError, "description must be a String" unless text.is_a?(String)
|
|
31
|
+
|
|
32
|
+
@_description = text
|
|
33
|
+
else
|
|
34
|
+
defined?(@_description) ? @_description : _dex_parent_description
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def _dex_reachable?(klass)
|
|
41
|
+
Object.const_get(klass.name) == klass
|
|
42
|
+
rescue NameError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def _dex_registry
|
|
47
|
+
if instance_variable_defined?(:@_registry)
|
|
48
|
+
@_registry
|
|
49
|
+
elsif superclass.respond_to?(:_dex_registry, true)
|
|
50
|
+
superclass.send(:_dex_registry)
|
|
51
|
+
else
|
|
52
|
+
@_registry = Set.new
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def _dex_parent_description
|
|
57
|
+
return nil unless superclass.respond_to?(:description)
|
|
58
|
+
return nil if superclass.instance_variable_defined?(:@_registry)
|
|
59
|
+
|
|
60
|
+
superclass.description
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/dex/tool.rb
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module Tool
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def from(operation_class)
|
|
8
|
+
_require_ruby_llm!
|
|
9
|
+
_build_tool(operation_class)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def all
|
|
13
|
+
_require_ruby_llm!
|
|
14
|
+
Operation.registry.sort_by(&:name).map { |klass| _build_tool(klass) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def from_namespace(namespace)
|
|
18
|
+
_require_ruby_llm!
|
|
19
|
+
prefix = "#{namespace}::"
|
|
20
|
+
Operation.registry
|
|
21
|
+
.select { |op| op.name&.start_with?(prefix) }
|
|
22
|
+
.sort_by(&:name)
|
|
23
|
+
.map { |klass| _build_tool(klass) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def explain_tool
|
|
27
|
+
_require_ruby_llm!
|
|
28
|
+
_build_explain_tool
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def _require_ruby_llm!
|
|
32
|
+
require "ruby_llm"
|
|
33
|
+
rescue LoadError
|
|
34
|
+
raise LoadError,
|
|
35
|
+
"Dex::Tool requires the ruby-llm gem. Add `gem 'ruby_llm'` to your Gemfile."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def _build_tool(operation_class) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
39
|
+
op = operation_class
|
|
40
|
+
schema = op.contract.to_json_schema
|
|
41
|
+
tool_description = _tool_description(op)
|
|
42
|
+
|
|
43
|
+
Class.new(RubyLLM::Tool) do
|
|
44
|
+
define_method(:name) { "dex_#{op.name.gsub("::", "_").downcase}" }
|
|
45
|
+
define_method(:description) { tool_description }
|
|
46
|
+
define_method(:params_schema) { schema }
|
|
47
|
+
|
|
48
|
+
define_method(:execute) do |**params|
|
|
49
|
+
coerced = params.transform_keys(&:to_sym)
|
|
50
|
+
result = op.new(**coerced).safe.call
|
|
51
|
+
case result
|
|
52
|
+
when Dex::Operation::Ok
|
|
53
|
+
value = result.value
|
|
54
|
+
value.respond_to?(:as_json) ? value.as_json : value
|
|
55
|
+
when Dex::Operation::Err
|
|
56
|
+
{ error: result.code, message: result.message, details: result.details }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def _tool_description(op)
|
|
63
|
+
parts = []
|
|
64
|
+
desc = op.description
|
|
65
|
+
parts << desc if desc
|
|
66
|
+
parts << op.name unless desc
|
|
67
|
+
|
|
68
|
+
guards = op.contract.guards
|
|
69
|
+
if guards.any?
|
|
70
|
+
messages = guards.map { |g| g[:message] || g[:name].to_s }
|
|
71
|
+
parts << "Preconditions: #{messages.join("; ")}."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
errors = op.contract.errors
|
|
75
|
+
parts << "Errors: #{errors.join(", ")}." if errors.any?
|
|
76
|
+
|
|
77
|
+
parts.join("\n")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def _build_explain_tool # rubocop:disable Metrics/MethodLength
|
|
81
|
+
Class.new(RubyLLM::Tool) do
|
|
82
|
+
define_method(:name) { "dex_explain" }
|
|
83
|
+
define_method(:description) { "Check if an operation can be executed with given params, without running it." }
|
|
84
|
+
define_method(:params_schema) do
|
|
85
|
+
{
|
|
86
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: {
|
|
89
|
+
"operation" => { type: "string", description: "Operation class name (e.g. 'Order::Place')" },
|
|
90
|
+
"params" => { type: "object", description: "Params to check" }
|
|
91
|
+
},
|
|
92
|
+
required: ["operation"],
|
|
93
|
+
additionalProperties: false
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
define_method(:execute) do |operation:, params: {}|
|
|
98
|
+
op_class = Dex::Operation.registry.find { |klass| klass.name == operation }
|
|
99
|
+
return { error: "unknown_operation", message: "Operation '#{operation}' not found in registry" } unless op_class
|
|
100
|
+
|
|
101
|
+
coerced = params.transform_keys(&:to_sym)
|
|
102
|
+
info = op_class.explain(**coerced)
|
|
103
|
+
{
|
|
104
|
+
callable: info[:callable],
|
|
105
|
+
guards: info[:guards],
|
|
106
|
+
once: info[:once],
|
|
107
|
+
lock: info[:lock]
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end.new
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private_class_method :_require_ruby_llm!, :_build_tool, :_tool_description, :_build_explain_tool
|
|
114
|
+
end
|
|
115
|
+
end
|