dexkit 0.6.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.
@@ -1,6 +1,6 @@
1
1
  # Dex::Operation — LLM Reference
2
2
 
3
- Copy this to your app's operations directory (e.g., `app/operations/AGENTS.md`) so coding agents know the full API when implementing and testing operations.
3
+ Install with `rake dex:guides` or copy manually to `app/operations/AGENTS.md`.
4
4
 
5
5
  ---
6
6
 
@@ -11,7 +11,7 @@ All examples below build on this operation unless noted otherwise:
11
11
  ```ruby
12
12
  class CreateUser < Dex::Operation
13
13
  prop :email, String
14
- prop :name, String
14
+ prop :name, String
15
15
  prop? :role, _Union("admin", "member"), default: "member"
16
16
 
17
17
  success _Ref(User)
@@ -36,9 +36,10 @@ end
36
36
  CreateUser.call(email: "a@b.com", name: "Alice") # shorthand for new(...).call
37
37
  CreateUser.new(email: "a@b.com", name: "Alice").safe.call # Ok/Err wrapper
38
38
  CreateUser.new(email: "a@b.com", name: "Alice").async.call # background job
39
+ CreateUser.new(email: "a@b.com", name: "Alice").once("key").call # call-site idempotency
39
40
  ```
40
41
 
41
- Use `new(...)` form when chaining modifiers (`.safe`, `.async`).
42
+ Use `new(...)` form when chaining modifiers (`.safe`, `.async`, `.once`).
42
43
 
43
44
  ---
44
45
 
@@ -63,38 +64,38 @@ Types use `===` for validation. All constructors available in the operation clas
63
64
  | `_Ref(Model)` | Model reference | `_Ref(User)`, `_Ref(Account, lock: true)` |
64
65
 
65
66
  ```ruby
66
- prop :name, String # any String
67
- prop :count, Integer # any Integer
68
- prop :amount, Float # any Float
69
- prop :amount, BigDecimal # any BigDecimal
70
- prop :data, Hash # any Hash
71
- prop :items, Array # any Array
72
- prop :active, _Boolean # true or false
73
- prop :role, Symbol # any Symbol
74
- prop :count, _Integer(1..) # Integer >= 1
75
- prop :count, _Integer(0..100) # Integer 0–100
76
- prop :name, _String(length: 1..255) # String with length constraint
77
- prop :score, _Float(0.0..1.0) # Float in range
78
- prop :tags, _Array(String) # Array of Strings
79
- prop :ids, _Array(Integer) # Array of Integers
80
- prop :matrix, _Array(_Array(Integer)) # nested typed arrays
67
+ prop :name, String # any String
68
+ prop :count, Integer # any Integer
69
+ prop :amount, Float # any Float
70
+ prop :amount, BigDecimal # any BigDecimal
71
+ prop :data, Hash # any Hash
72
+ prop :items, Array # any Array
73
+ prop :active, _Boolean # true or false
74
+ prop :role, Symbol # any Symbol
75
+ prop :count, _Integer(1..) # Integer >= 1
76
+ prop :count, _Integer(0..100) # Integer 0–100
77
+ prop :name, _String(length: 1..255) # String with length constraint
78
+ prop :score, _Float(0.0..1.0) # Float in range
79
+ prop :tags, _Array(String) # Array of Strings
80
+ prop :ids, _Array(Integer) # Array of Integers
81
+ prop :matrix, _Array(_Array(Integer)) # nested typed arrays
81
82
  prop :currency, _Union("USD", "EUR", "GBP") # enum of values
82
- prop :id, _Union(String, Integer) # union of types
83
- prop :label, _Nilable(String) # String or nil
84
- prop :meta, _Hash(Symbol, String) # Hash with typed keys+values
85
- prop :pair, _Tuple(String, Integer) # fixed-size typed array
86
- prop :name, _Frozen(String) # must be frozen
87
- prop :handler, _Callable # anything responding to .call
88
- prop :handler, _Interface(:call, :arity) # responds to listed methods
89
- prop :user, _Ref(User) # Dex-specific: model by instance or ID
90
- prop :account, _Ref(Account, lock: true) # Dex-specific: with row lock
91
- prop :title, String, default: "Untitled" # default value
92
- prop? :note, String # optional (nilable, default: nil)
83
+ prop :id, _Union(String, Integer) # union of types
84
+ prop :label, _Nilable(String) # String or nil
85
+ prop :meta, _Hash(Symbol, String) # Hash with typed keys+values
86
+ prop :pair, _Tuple(String, Integer) # fixed-size typed array
87
+ prop :name, _Frozen(String) # must be frozen
88
+ prop :handler, _Callable # anything responding to .call
89
+ prop :handler, _Interface(:call, :arity) # responds to listed methods
90
+ prop :user, _Ref(User) # Dex-specific: model by instance or ID
91
+ prop :account, _Ref(Account, lock: true) # Dex-specific: with row lock
92
+ prop :title, String, default: "Untitled" # default value
93
+ prop? :note, String # optional (nilable, default: nil)
93
94
  ```
94
95
 
95
96
  ### _Ref(Model)
96
97
 
97
- Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE). Instances pass through without re-locking. In serialization (recording, async), stores model ID only.
98
+ Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE). Instances pass through without re-locking. In serialization (recording, async), stores model ID only. IDs are treated as strings in JSON Schema – this supports integer PKs, UUIDs, and Mongoid BSON::ObjectId equally.
98
99
 
99
100
  Outside the class body (e.g., in tests), use `Dex::RefType.new(Model)` instead of `_Ref(Model)`.
100
101
 
@@ -118,7 +119,163 @@ error :email_taken, :invalid_email
118
119
 
119
120
  Both inherit from parent class. Without `error` declaration, any code is accepted.
120
121
 
121
- **Introspection:** `MyOp.contract` returns a frozen `Data` with `params`, `success`, `errors` fields. Supports pattern matching and `to_h`.
122
+ **Introspection:** `MyOp.contract` returns a frozen `Data` with `params`, `success`, `errors`, `guards` fields. Supports pattern matching and `to_h`.
123
+
124
+ ---
125
+
126
+ ## Ambient Context
127
+
128
+ Map props to ambient context keys so they auto-fill from `Dex.with_context` when not passed explicitly. Explicit kwargs always win.
129
+
130
+ ```ruby
131
+ class Order::Place < Dex::Operation
132
+ prop :product, _Ref(Product)
133
+ prop :customer, _Ref(Customer)
134
+ prop :locale, Symbol
135
+
136
+ context customer: :current_customer # prop :customer ← Dex.context[:current_customer]
137
+ context :locale # shorthand: prop :locale ← Dex.context[:locale]
138
+ end
139
+ ```
140
+
141
+ **Setting context** (controller, middleware):
142
+
143
+ ```ruby
144
+ Dex.with_context(current_customer: customer, locale: I18n.locale) do
145
+ Order::Place.call(product: product) # customer + locale auto-filled
146
+ end
147
+ ```
148
+
149
+ **Resolution order:** explicit kwarg → ambient context → prop default → TypeError (if required).
150
+
151
+ **In tests** — just pass everything explicitly. No `Dex.with_context` needed:
152
+
153
+ ```ruby
154
+ Order::Place.call(product: product, customer: customer, locale: :en)
155
+ ```
156
+
157
+ **Nesting** supported — inner blocks merge with outer, restore on exit. Nested operations inherit the same ambient context.
158
+
159
+ **Works with guards** — context-mapped props are available in guard blocks and `callable?`:
160
+
161
+ ```ruby
162
+ Dex.with_context(current_customer: customer) do
163
+ Order::Place.callable?(product: product)
164
+ end
165
+ ```
166
+
167
+ **Works with optional props** (`prop?`) — if ambient context has the key, it fills in. If not, the prop is nil.
168
+
169
+ **Introspection:** `MyOp.context_mappings` returns `{ customer: :current_customer, locale: :locale }`.
170
+
171
+ **DSL validation:** `context user: :current_user` raises `ArgumentError` if no `prop :user` has been declared. Context declarations must come after the props they reference.
172
+
173
+ ---
174
+
175
+ ## Guards
176
+
177
+ Inline precondition checks. The guard name is the error code, the block detects the **threat** (truthy = threat detected = operation fails):
178
+
179
+ ```ruby
180
+ class PublishPost < Dex::Operation
181
+ prop :post, _Ref(Post)
182
+ prop :user, _Ref(User)
183
+
184
+ guard :unauthorized, "Only the author or admins can publish" do
185
+ !user.admin? && post.author != user
186
+ end
187
+
188
+ guard :already_published, "Post must be in draft state" do
189
+ post.published?
190
+ end
191
+
192
+ def perform
193
+ post.update!(published_at: Time.current)
194
+ post
195
+ end
196
+ end
197
+ ```
198
+
199
+ - Guards run in declaration order, before `perform`, after `rescue`
200
+ - All independent guards run – failures are collected, not short-circuited
201
+ - Guard names are auto-declared as error codes (no separate `error :unauthorized` needed)
202
+ - Same error code usable with `error!` in `perform`
203
+
204
+ **Dependencies:** skip dependent guards when a dependency fails:
205
+
206
+ ```ruby
207
+ guard :missing_author, "Author must be present" do
208
+ author.blank?
209
+ end
210
+
211
+ guard :unpaid_author, "Author must be a paid subscriber", requires: :missing_author do
212
+ author.free_plan?
213
+ end
214
+ ```
215
+
216
+ If author is nil: only `:missing_author` is reported. `:unpaid_author` is skipped.
217
+
218
+ **Introspection** – check guards without running `perform`:
219
+
220
+ ```ruby
221
+ PublishPost.callable?(post: post, user: user) # => true/false
222
+ PublishPost.callable?(:unauthorized, post: post, user: user) # check specific guard
223
+ result = PublishPost.callable(post: post, user: user) # => Ok or Err with details
224
+ result.details # => [{ guard: :unauthorized, message: "..." }, ...]
225
+ ```
226
+
227
+ `callable` bypasses the pipeline – no locks, transactions, recording, or callbacks. Cheap and side-effect-free.
228
+
229
+ **Contract:** `contract.guards` returns guard metadata. `contract.errors` includes guard codes.
230
+
231
+ **Inheritance:** parent guards run first, child guards appended.
232
+
233
+ **DSL validation:** code must be Symbol, block required, `requires:` must reference previously declared guards, duplicates raise `ArgumentError`.
234
+
235
+ ---
236
+
237
+ ## Explain
238
+
239
+ Full preflight check — resolves context, coerces props, evaluates guards, computes derived keys, reports settings. No side effects, `perform` never runs.
240
+
241
+ ```ruby
242
+ info = Order::Place.explain(product: product, customer: customer, quantity: 2)
243
+ ```
244
+
245
+ Returns a frozen Hash:
246
+
247
+ ```ruby
248
+ info = Order::Place.explain(product: product, customer: customer, quantity: 2)
249
+ # => {
250
+ # operation: "Order::Place",
251
+ # props: { product: #<Product>, customer: #<Customer>, quantity: 2 },
252
+ # context: {
253
+ # resolved: { customer: #<Customer> },
254
+ # mappings: { customer: :current_customer },
255
+ # source: { customer: :ambient } # :ambient, :explicit, or :default
256
+ # },
257
+ # guards: {
258
+ # passed: true,
259
+ # results: [{ name: :out_of_stock, passed: true }, ...]
260
+ # },
261
+ # once: { active: true, key: "Order::Place/product_id=7", status: :fresh, expires_in: nil },
262
+ # lock: { active: true, key: "order:7", timeout: nil },
263
+ # record: { enabled: true, params: true, result: true },
264
+ # transaction: { enabled: true },
265
+ # rescue_from: { "Stripe::CardError" => :card_declined },
266
+ # callbacks: { before: 1, after: 2, around: 0 },
267
+ # pipeline: [:result, :guard, :once, :lock, :record, :transaction, :rescue, :callback],
268
+ # callable: true
269
+ # }
270
+ ```
271
+
272
+ - Invalid props (`Literal::TypeError`, `ArgumentError`) return a partial result with `info[:error]` — class-level info still available, instance-dependent sections degrade to empty/nil. Static lock keys preserved. Context source uses `:missing` for props without defaults. Other errors propagate normally
273
+ - `info[:callable]` is a full preflight verdict — checks guards AND once blocking statuses; always `false` when props are invalid
274
+ - Once status: `:fresh` (new), `:exists` (would replay), `:expired`, `:pending` (in-flight), `:invalid` (nil key), `:misconfigured` (anonymous op, missing record step, missing column), `:unavailable` (no backend)
275
+ - Guard results include `message:` on failures and `skipped: true` when a guard was skipped via `requires:` dependency
276
+ - Custom middleware can contribute via `_name_explain(instance, info)` class methods
277
+
278
+ **Use cases:** console debugging, admin tooling, LLM agent preflight, test assertions.
122
279
 
123
280
  ---
124
281
 
@@ -154,13 +311,13 @@ begin
154
311
  CreateUser.call(email: "bad", name: "A")
155
312
  rescue Dex::Error => e
156
313
  case e
157
- in {code: :not_found} then handle_not_found
158
- in {code: :validation_failed, details: {field:}} then handle_field(field)
314
+ in { code: :not_found } then handle_not_found
315
+ in { code: :validation_failed, details: { field: } } then handle_field(field)
159
316
  end
160
317
  end
161
318
  ```
162
319
 
163
- **Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks and recording. `success!` commits, runs `after` callbacks, records normally.
320
+ **Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks, but are still recorded (status `error`). `success!` commits, runs `after` callbacks, records normally (status `completed`).
164
321
 
165
322
  ---
166
323
 
@@ -187,7 +344,7 @@ result.value! # re-raises Dex::Error
187
344
 
188
345
  ```ruby
189
346
  case CreateUser.new(email: "a@b.com", name: "Alice").safe.call
190
- in Dex::Ok(name:) then puts "Created #{name}"
347
+ in Dex::Ok(name:) then puts "Created #{name}"
191
348
  in Dex::Err(code: :email_taken) then puts "Already exists"
192
349
  end
193
350
  ```
@@ -202,10 +359,10 @@ Map exceptions to structured `Dex::Error` codes — eliminates begin/rescue boil
202
359
 
203
360
  ```ruby
204
361
  class ChargeCard < Dex::Operation
205
- rescue_from Stripe::CardError, as: :card_declined
206
- rescue_from Stripe::RateLimitError, as: :rate_limited
362
+ rescue_from Stripe::CardError, as: :card_declined
363
+ rescue_from Stripe::RateLimitError, as: :rate_limited
207
364
  rescue_from Net::OpenTimeout, Net::ReadTimeout, as: :timeout
208
- rescue_from Stripe::APIError, as: :provider_error, message: "Stripe is down"
365
+ rescue_from Stripe::APIError, as: :provider_error, message: "Stripe is down"
209
366
 
210
367
  def perform
211
368
  Stripe::Charge.create(amount: amount, source: token)
@@ -226,7 +383,7 @@ end
226
383
  class ProcessOrder < Dex::Operation
227
384
  before :validate_stock # symbol → instance method
228
385
  before -> { log("starting") } # lambda (instance_exec'd)
229
- after :send_confirmation # runs after successful perform
386
+ after :send_confirmation # runs after successful perform
230
387
  around :with_timing # wraps everything, must yield
231
388
 
232
389
  def validate_stock
@@ -327,27 +484,99 @@ Record execution to database. Requires `Dex.configure { |c| c.record_class = Ope
327
484
 
328
485
  ```ruby
329
486
  create_table :operation_records do |t|
330
- t.string :name # Required: operation class name
331
- t.jsonb :params # Optional: serialized props
332
- t.jsonb :response # Optional: serialized result
333
- t.string :status # Optional: pending/running/done/failed (for async)
334
- t.string :error # Optional: error code on failure
335
- t.datetime :performed_at # Optional
487
+ t.string :name, null: false # operation class name
488
+ t.jsonb :params # serialized props (nil = not captured)
489
+ t.jsonb :result # serialized return value
490
+ t.string :status, null: false # pending/running/completed/error/failed
491
+ t.string :error_code # Dex::Error code or exception class
492
+ t.string :error_message # human-readable message
493
+ t.jsonb :error_details # structured details hash
494
+ t.string :once_key # idempotency key (used by `once`)
495
+ t.datetime :once_key_expires_at # key expiry (used by `once`)
496
+ t.datetime :performed_at # execution completion timestamp
336
497
  t.timestamps
337
498
  end
499
+
500
+ add_index :operation_records, :name
501
+ add_index :operation_records, :status
502
+ add_index :operation_records, [:name, :status]
338
503
  ```
339
504
 
340
505
  Control per-operation:
341
506
 
342
507
  ```ruby
343
508
  record false # disable entirely
344
- record response: false # params only
345
- record params: false # response only
509
+ record result: false # params only
510
+ record params: false # result only
346
511
  ```
347
512
 
348
- Recording happens inside the transaction rolled back on `error!`/`assert!`. Missing columns silently skipped.
513
+ All outcomes are recorded — success (`completed`), business errors (`error`), and exceptions (`failed`). Recording runs outside the operation's own transaction so error records survive its rollbacks. Records still participate in ambient transactions (e.g., an outer operation's transaction). Missing columns silently skipped.
514
+
515
+ Status values: `pending` (async enqueued), `running` (async executing), `completed` (success), `error` (business error via `error!`), `failed` (unhandled exception).
516
+
517
+ When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params.
518
+
519
+ ---
520
+
521
+ ## Idempotency (once)
522
+
523
+ Prevent duplicate execution with `once`. Requires recording to be configured (uses the record backend to store and look up idempotency keys).
524
+
525
+ **Class-level declaration:**
526
+
527
+ ```ruby
528
+ class ChargeOrder < Dex::Operation
529
+ prop :order_id, Integer
530
+ once :order_id # key: "ChargeOrder/order_id=123"
531
+
532
+ def perform
533
+ Stripe::Charge.create(amount: order.total)
534
+ end
535
+ end
536
+ ```
537
+
538
+ Key forms:
539
+
540
+ ```ruby
541
+ once :order_id # single prop → "ClassName/order_id=1"
542
+ once :merchant_id, :plan_id # composite → "ClassName/merchant_id=1/plan_id=2" (sorted)
543
+ once # bare — all props as key
544
+ once { "payment-#{order_id}" } # block — custom key (no auto scoping)
545
+ once :user_id, expires_in: 24.hours # key expires after duration
546
+ ```
547
+
548
+ **Call-site key** — override or add idempotency at the call site:
549
+
550
+ ```ruby
551
+ MyOp.new(payload: "data").once("webhook-123").call # explicit key
552
+ MyOp.new(order_id: 1).once(nil).call # bypass once guard entirely
553
+ ```
554
+
555
+ Works without a class-level `once` declaration — useful for one-off idempotency from controllers or jobs.
556
+
557
+ **Replay behavior:**
558
+
559
+ - Success results and business errors (`error!`) are replayed from the stored record. The operation does not re-execute.
560
+ - Unhandled exceptions release the key — the next call retries normally.
561
+ - Works with `.safe.call` (replays as `Ok`/`Err`) and `.async.call`.
562
+
563
+ **Clearing keys:**
564
+
565
+ ```ruby
566
+ ChargeOrder.clear_once!(order_id: 1) # by prop values (builds scoped key)
567
+ ChargeOrder.clear_once!("webhook-123") # by raw string key
568
+ ```
569
+
570
+ Clearing is idempotent — clearing a non-existent key is a no-op. After clearing, the next call executes normally.
571
+
572
+ **Pipeline position:** result → **once** → lock → record → transaction → rescue → guard → callback. The once check runs before locking and recording, so duplicate calls short-circuit early.
349
573
 
350
- When both async and recording are enabled, dexkit automatically stores only the record ID in the job payload instead of full params. The record tracks `status` (pending → running → done/failed) and `error` (code or exception class name).
574
+ **Requirements:**
575
+
576
+ - Record backend must be configured (`Dex.configure { |c| c.record_class = OperationRecord }`)
577
+ - The record table must have `once_key` and `once_key_expires_at` columns (see Recording schema above)
578
+ - `once` cannot be declared with `record false` — raises `ArgumentError`
579
+ - Only one `once` declaration per operation
351
580
 
352
581
  ---
353
582
 
@@ -390,7 +619,7 @@ class CreateUserTest < Minitest::Test
390
619
 
391
620
  def test_example
392
621
  result = call_operation(email: "a@b.com", name: "Alice") # => Ok or Err (safe)
393
- value = call_operation!(email: "a@b.com", name: "Alice") # => raw value or raises
622
+ value = call_operation!(email: "a@b.com", name: "Alice") # => raw value or raises
394
623
  end
395
624
  end
396
625
  ```
@@ -445,6 +674,17 @@ assert_contract(
445
674
  )
446
675
  ```
447
676
 
677
+ ### Guard Assertions
678
+
679
+ ```ruby
680
+ assert_callable(post: post, user: user) # all guards pass
681
+ assert_callable(PublishPost, post: post, user: user) # explicit class
682
+ refute_callable(:unauthorized, post: post, user: user) # specific guard fails
683
+ refute_callable(PublishPost, :unauthorized, post: post, user: user)
684
+ ```
685
+
686
+ Guard failures on the normal `call` path produce `Dex::Error`, so `assert_operation_error` and `assert_err` also work.
687
+
448
688
  ### Param Validation
449
689
 
450
690
  ```ruby
@@ -526,7 +766,9 @@ Global log of all operation calls:
526
766
  Dex::TestLog.calls # all entries
527
767
  Dex::TestLog.find(CreateUser) # filter by class
528
768
  Dex::TestLog.find(CreateUser, email: "a@b.com") # filter by class + params
529
- Dex::TestLog.size; Dex::TestLog.empty?; Dex::TestLog.clear!
769
+ Dex::TestLog.size
770
+ Dex::TestLog.empty?
771
+ Dex::TestLog.clear!
530
772
  Dex::TestLog.summary # human-readable for failure messages
531
773
  ```
532
774
 
@@ -583,4 +825,63 @@ end
583
825
 
584
826
  ---
585
827
 
828
+ ## Registry, Export & Description
829
+
830
+ ### Description
831
+
832
+ Operations can declare a human-readable description. Props can include `desc:`:
833
+
834
+ ```ruby
835
+ class Order::Place < Dex::Operation
836
+ description "Places a new order, charges payment, and schedules fulfillment"
837
+
838
+ prop :product, _Ref(Product), desc: "Product to order"
839
+ prop :quantity, _Integer(1..), desc: "Number of units"
840
+ end
841
+ ```
842
+
843
+ Descriptions appear in `contract.to_h`, `to_json_schema`, `explain`, and LLM tool definitions.
844
+
845
+ ### Registry
846
+
847
+ ```ruby
848
+ Dex::Operation.registry # => #<Set: {Order::Place, Order::Cancel, ...}>
849
+ Dex::Operation.deregister(klass) # remove from registry (useful in tests)
850
+ Dex::Operation.clear! # empty the registry
851
+ ```
852
+
853
+ Only named, reachable classes are included. Anonymous classes and stale objects from code reloads are excluded. Populates lazily via `inherited` — in Rails, `eager_load!` to get the full list.
854
+
855
+ ### Export
856
+
857
+ ```ruby
858
+ Order::Place.contract.to_h
859
+ # => { name: "Order::Place", description: "...", params: { product: { type: "Ref(Product)", required: true, desc: "..." } }, ... }
860
+
861
+ Order::Place.contract.to_json_schema # params input schema (default)
862
+ Order::Place.contract.to_json_schema(section: :success) # success return schema
863
+ Order::Place.contract.to_json_schema(section: :errors) # error catalog schema
864
+ Order::Place.contract.to_json_schema(section: :full) # everything
865
+
866
+ Dex::Operation.export # all operations as hashes
867
+ Dex::Operation.export(format: :json_schema) # all as JSON Schema
868
+ ```
869
+
870
+ ### LLM Tools (ruby-llm integration)
871
+
872
+ ```ruby
873
+ chat = RubyLLM.chat
874
+ chat.with_tools(*Dex::Tool.all) # all operations as tools
875
+ chat.with_tools(*Dex::Tool.from_namespace("Order")) # namespace filter
876
+ chat.with_tools(Dex::Tool.explain_tool) # preflight check tool
877
+
878
+ Dex.with_context(current_user: user) do
879
+ chat.ask("Place an order for 2 units of product #42")
880
+ end
881
+ ```
882
+
883
+ Requires `gem 'ruby_llm'` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
884
+
885
+ ---
886
+
586
887
  **End of reference.**
data/guides/llm/QUERY.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Dex::Query — LLM Reference
2
2
 
3
- Copy this to your app's queries directory (e.g., `app/queries/AGENTS.md`) so coding agents know the full API when implementing and testing queries.
3
+ Install with `rake dex:guides` or copy manually to `app/queries/AGENTS.md`.
4
4
 
5
5
  ---
6
6
 
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ # Shared context DSL for Operation and Event.
5
+ #
6
+ # Maps declared props to ambient context keys so they can be auto-filled
7
+ # from Dex.context when not passed explicitly as kwargs.
8
+ module ContextSetup
9
+ extend Dex::Concern
10
+
11
+ module ClassMethods
12
+ def context(*names, **mappings)
13
+ names.each do |name|
14
+ unless name.is_a?(Symbol)
15
+ raise ArgumentError, "context shorthand must be a Symbol, got: #{name.inspect}"
16
+ end
17
+ mappings[name] = name
18
+ end
19
+
20
+ raise ArgumentError, "context requires at least one mapping" if mappings.empty?
21
+
22
+ mappings.each do |prop_name, context_key|
23
+ unless _context_prop_declared?(prop_name)
24
+ raise ArgumentError,
25
+ "context references undeclared prop :#{prop_name}. Declare the prop before calling context."
26
+ end
27
+ unless context_key.is_a?(Symbol)
28
+ raise ArgumentError,
29
+ "context key must be a Symbol, got: #{context_key.inspect} for prop :#{prop_name}"
30
+ end
31
+ end
32
+
33
+ _context_own.merge!(mappings)
34
+ end
35
+
36
+ def context_mappings
37
+ parent = superclass.respond_to?(:context_mappings) ? superclass.context_mappings : {}
38
+ parent.merge(_context_own)
39
+ end
40
+
41
+ def new(**kwargs)
42
+ mappings = context_mappings
43
+ unless mappings.empty?
44
+ ambient = Dex.context
45
+ mappings.each do |prop_name, context_key|
46
+ next if kwargs.key?(prop_name)
47
+ kwargs[prop_name] = ambient[context_key] if ambient.key?(context_key)
48
+ end
49
+ end
50
+ super
51
+ end
52
+
53
+ private
54
+
55
+ def _context_own
56
+ @_context_own_mappings ||= {}
57
+ end
58
+
59
+ def _context_prop_declared?(name)
60
+ respond_to?(:literal_properties) && literal_properties.any? { |p| p.name == name }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Event
5
+ module Export
6
+ module_function
7
+
8
+ def build_hash(source)
9
+ h = {}
10
+ h[:name] = source.name if source.name
11
+ desc = source.description
12
+ h[:description] = desc if desc
13
+ h[:props] = _serialize_props(source)
14
+ h
15
+ end
16
+
17
+ def build_json_schema(source)
18
+ descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
19
+ properties = {}
20
+ required = []
21
+
22
+ if source.respond_to?(:literal_properties)
23
+ source.literal_properties.each do |prop|
24
+ prop_desc = descs[prop.name]
25
+ schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
26
+ properties[prop.name.to_s] = schema
27
+ required << prop.name.to_s if prop.required?
28
+ end
29
+ end
30
+
31
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
32
+ result[:title] = source.name if source.name
33
+ desc = source.description
34
+ result[:description] = desc if desc
35
+ result[:type] = "object"
36
+ result[:properties] = properties unless properties.empty?
37
+ result[:required] = required unless required.empty?
38
+ result[:additionalProperties] = false
39
+ result
40
+ end
41
+
42
+ def _serialize_props(source)
43
+ return {} unless source.respond_to?(:literal_properties)
44
+
45
+ descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
46
+ source.literal_properties.each_with_object({}) do |prop, hash|
47
+ entry = { type: TypeSerializer.to_string(prop.type), required: prop.required? }
48
+ entry[:desc] = descs[prop.name] if descs[prop.name]
49
+ hash[prop.name] = entry
50
+ end
51
+ end
52
+
53
+ private_class_method :_serialize_props
54
+ end
55
+ end
56
+ end