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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -2
- data/README.md +62 -281
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +1 -7
- data/guides/llm/OPERATION.md +88 -54
- data/guides/llm/QUERY.md +6 -0
- data/guides/llm/TOOL.md +308 -0
- data/lib/dex/event/bus.rb +1 -3
- data/lib/dex/event/handler.rb +1 -2
- data/lib/dex/event/metadata.rb +3 -15
- data/lib/dex/event/processor.rb +1 -15
- data/lib/dex/event.rb +1 -3
- data/lib/dex/id.rb +92 -5
- data/lib/dex/operation/async_proxy.rb +10 -2
- data/lib/dex/operation/guard_wrapper.rb +1 -1
- data/lib/dex/operation/outcome.rb +14 -0
- data/lib/dex/operation/result_wrapper.rb +0 -12
- data/lib/dex/operation/test_helpers/assertions.rb +0 -112
- data/lib/dex/operation/ticket.rb +268 -0
- data/lib/dex/operation.rb +1 -0
- data/lib/dex/operation_failed.rb +14 -0
- data/lib/dex/timeout.rb +14 -0
- data/lib/dex/tool.rb +388 -5
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +19 -3
- metadata +5 -3
- data/lib/dex/event/trace.rb +0 -43
- data/lib/dex/event_test_helpers.rb +0 -3
data/guides/llm/OPERATION.md
CHANGED
|
@@ -111,7 +111,7 @@ Optional declarations documenting intent and catching mistakes at runtime.
|
|
|
111
111
|
success _Ref(User) # perform must return a User (or nil)
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
**`error(*codes)`** — restricts which codes `error
|
|
114
|
+
**`error(*codes)`** — restricts which codes `error!` accepts (raises `ArgumentError` on undeclared):
|
|
115
115
|
|
|
116
116
|
```ruby
|
|
117
117
|
error :email_taken, :invalid_email
|
|
@@ -297,13 +297,6 @@ success!(user) # return value early
|
|
|
297
297
|
success!(name: "John", age: 30) # kwargs become Hash
|
|
298
298
|
```
|
|
299
299
|
|
|
300
|
-
**`assert!(code, &block)` / `assert!(value, code)`** — returns value if truthy, otherwise `error!(code)`:
|
|
301
|
-
|
|
302
|
-
```ruby
|
|
303
|
-
user = assert!(:not_found) { User.find_by(id: id) }
|
|
304
|
-
assert!(user.active?, :inactive)
|
|
305
|
-
```
|
|
306
|
-
|
|
307
300
|
**Dex::Error** has `code` (Symbol), `message` (String, defaults to code.to_s), `details` (any). Pattern matching:
|
|
308
301
|
|
|
309
302
|
```ruby
|
|
@@ -317,7 +310,7 @@ rescue Dex::Error => e
|
|
|
317
310
|
end
|
|
318
311
|
```
|
|
319
312
|
|
|
320
|
-
**Key differences:** `error
|
|
313
|
+
**Key differences:** `error!` rolls back transaction, skips `after` callbacks, but is still recorded (status `error`). `success!` commits, runs `after` callbacks, records normally (status `completed`).
|
|
321
314
|
|
|
322
315
|
---
|
|
323
316
|
|
|
@@ -351,6 +344,10 @@ end
|
|
|
351
344
|
|
|
352
345
|
`Ok`/`Err` are available inside operations without prefix. In other contexts (controllers, POROs), use `Dex::Ok`/`Dex::Err` or `include Dex::Match`.
|
|
353
346
|
|
|
347
|
+
**Deconstruct forms:**
|
|
348
|
+
- Hash: `in Dex::Ok(key:)` — destructures value's keys. `in Dex::Err(code:, message:)` — named error fields.
|
|
349
|
+
- Array: `in Dex::Ok[value]` — binds entire value. `in Dex::Err[error]` — binds the `Dex::Error` instance.
|
|
350
|
+
|
|
354
351
|
---
|
|
355
352
|
|
|
356
353
|
## Rescue Mapping
|
|
@@ -464,16 +461,82 @@ On timeout: raises `Dex::Error(code: :lock_timeout)`. Works with `.safe`.
|
|
|
464
461
|
Enqueue as background jobs (requires ActiveJob):
|
|
465
462
|
|
|
466
463
|
```ruby
|
|
467
|
-
CreateUser.new(email: "a@b.com", name: "Alice").async.call
|
|
468
|
-
CreateUser.new(email: "a@b.com", name: "Alice").async(queue: "urgent").call
|
|
469
|
-
CreateUser.new(email: "a@b.com", name: "Alice").async(in: 5.minutes).call
|
|
470
|
-
CreateUser.new(email: "a@b.com", name: "Alice").async(at: 1.hour.from_now).call
|
|
464
|
+
ticket = CreateUser.new(email: "a@b.com", name: "Alice").async.call
|
|
465
|
+
ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(queue: "urgent").call
|
|
466
|
+
ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(in: 5.minutes).call
|
|
467
|
+
ticket = CreateUser.new(email: "a@b.com", name: "Alice").async(at: 1.hour.from_now).call
|
|
471
468
|
```
|
|
472
469
|
|
|
473
470
|
Class-level defaults: `async queue: "mailers"`. Runtime options override.
|
|
474
471
|
|
|
475
472
|
Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref` — all handled). Non-serializable props raise `ArgumentError` at enqueue time.
|
|
476
473
|
|
|
474
|
+
### Ticket
|
|
475
|
+
|
|
476
|
+
`async.call` returns a `Dex::Operation::Ticket` with `record` (operation record if recording enabled) and `job` (the enqueued job).
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
ticket.id # record ID
|
|
480
|
+
ticket.operation_name # operation class name
|
|
481
|
+
ticket.status # "pending"/"running"/"completed"/"error"/"failed"
|
|
482
|
+
ticket.recorded? # true if record strategy was used
|
|
483
|
+
ticket.pending? # status predicates
|
|
484
|
+
ticket.terminal? # completed? || error? || failed?
|
|
485
|
+
ticket.reload # refresh from DB, returns self
|
|
486
|
+
ticket.to_param # id.to_s (Rails path helpers)
|
|
487
|
+
ticket.as_json # { "id": ..., "name": ..., "status": ..., "result"?: ..., "error"?: ... }
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`Ticket.from_record(record)` constructs from any operation record (for polling endpoints/dashboards).
|
|
491
|
+
|
|
492
|
+
Record-dependent methods raise `ArgumentError` when `record` is nil (direct strategy without recording).
|
|
493
|
+
|
|
494
|
+
### Outcome Reconstruction
|
|
495
|
+
|
|
496
|
+
`ticket.outcome` reconstructs `Ok`/`Err` from the record — same types as `.safe.call`:
|
|
497
|
+
|
|
498
|
+
- `completed` → `Ok(result)` with deep-symbolized keys
|
|
499
|
+
- `error` → `Err(Dex::Error)` with symbolized code/details
|
|
500
|
+
- `failed`/`pending`/`running` → `nil`
|
|
501
|
+
|
|
502
|
+
Never raises, never reloads. Call `reload` first for fresh data.
|
|
503
|
+
|
|
504
|
+
### wait / wait!
|
|
505
|
+
|
|
506
|
+
Speculative sync — poll until terminal or timeout:
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
# Safe mode (Ok/Err/nil)
|
|
510
|
+
case ticket.wait(3.seconds)
|
|
511
|
+
in Dex::Ok(url:)
|
|
512
|
+
redirect_to url
|
|
513
|
+
in Dex::Err(code:, message:)
|
|
514
|
+
flash[:error] = message
|
|
515
|
+
redirect_to fallback_path
|
|
516
|
+
else
|
|
517
|
+
redirect_to pending_path(ticket)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Strict mode (value or exception)
|
|
521
|
+
result = ticket.wait!(3.seconds)
|
|
522
|
+
redirect_to result[:url]
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
| | Success | Business Error | Infra Crash | Timeout |
|
|
526
|
+
|---|---|---|---|---|
|
|
527
|
+
| `call` | value | `Dex::Error` | exception | n/a |
|
|
528
|
+
| `safe.call` | `Ok` | `Err` | exception | n/a |
|
|
529
|
+
| `wait!(t)` | value | `Dex::Error` | `OperationFailed` | `Dex::Timeout` |
|
|
530
|
+
| `wait(t)` | `Ok` | `Err` | `OperationFailed` | `nil` |
|
|
531
|
+
|
|
532
|
+
Options: `wait(timeout, interval: 0.2)`. Interval accepts a number or callable (`->(n) { ... }`).
|
|
533
|
+
|
|
534
|
+
`Dex::OperationFailed` (inherits `StandardError`): `operation_name`, `exception_class`, `exception_message`.
|
|
535
|
+
`Dex::Timeout` (inherits `StandardError`): `timeout`, `ticket_id`, `operation_name`.
|
|
536
|
+
Neither inherits `Dex::Error` — `rescue Dex::Error` never catches them.
|
|
537
|
+
|
|
538
|
+
`safe` and `async` are non-composable — `op.safe.async` and `op.async.safe` raise `NoMethodError` with prescriptive messages.
|
|
539
|
+
|
|
477
540
|
---
|
|
478
541
|
|
|
479
542
|
## Recording
|
|
@@ -544,10 +607,17 @@ end
|
|
|
544
607
|
Dex::Trace.trace_id # => "tr_..."
|
|
545
608
|
Dex::Trace.current # => [{ type: :actor, ... }, { type: :operation, ... }]
|
|
546
609
|
Dex::Trace.to_s # => "user:42 > Order::Place(op_2nFg7K)"
|
|
610
|
+
Dex.actor # => { type: "user", id: "123" } or nil
|
|
547
611
|
```
|
|
548
612
|
|
|
613
|
+
`Dex.actor` returns the actor hash (reconstituted to the shape you passed in), or `nil`. Use it in `perform` when you need to write actor info into domain models.
|
|
614
|
+
|
|
615
|
+
`Dex.system(name = nil)` is a convenience for background jobs: `Dex::Trace.start(actor: Dex.system("payroll")) { ... }`.
|
|
616
|
+
|
|
549
617
|
Tracing is always on – no opt-in needed. Async operations serialize and restore the trace automatically. When recording is enabled, `trace_id`, `actor_type`, `actor_id`, and `trace` are persisted alongside the usual record fields.
|
|
550
618
|
|
|
619
|
+
IDs are generated by `Dex::Id` – a general-purpose Stripe-style ID generator. Use it for your own models: `Dex::Id.generate("ord_")`. Parse with `Dex::Id.parse(id)` to extract prefix, timestamp, and random components.
|
|
620
|
+
|
|
551
621
|
---
|
|
552
622
|
|
|
553
623
|
## Idempotency (once)
|
|
@@ -673,19 +743,6 @@ refute_err result
|
|
|
673
743
|
refute_err result, :not_found # Ok OR different code
|
|
674
744
|
```
|
|
675
745
|
|
|
676
|
-
### One-Liner Assertions
|
|
677
|
-
|
|
678
|
-
Call + assert in one step:
|
|
679
|
-
|
|
680
|
-
```ruby
|
|
681
|
-
assert_operation(email: "a@b.com", name: "Alice") # Ok
|
|
682
|
-
assert_operation(CreateUser, email: "a@b.com", name: "Alice") # explicit class
|
|
683
|
-
assert_operation(email: "a@b.com", name: "Alice", returns: user) # check value
|
|
684
|
-
|
|
685
|
-
assert_operation_error(:invalid_email, email: "bad", name: "A")
|
|
686
|
-
assert_operation_error(CreateUser, :email_taken, email: "taken@b.com", name: "A")
|
|
687
|
-
```
|
|
688
|
-
|
|
689
746
|
### Contract Assertions
|
|
690
747
|
|
|
691
748
|
```ruby
|
|
@@ -712,7 +769,7 @@ refute_callable(:unauthorized, post: post, user: user) # specific guar
|
|
|
712
769
|
refute_callable(PublishPost, :unauthorized, post: post, user: user)
|
|
713
770
|
```
|
|
714
771
|
|
|
715
|
-
Guard failures on the normal `call` path produce `Dex::Error`, so `
|
|
772
|
+
Guard failures on the normal `call` path produce `Dex::Error`, so `assert_err` also works.
|
|
716
773
|
|
|
717
774
|
### Param Validation
|
|
718
775
|
|
|
@@ -738,21 +795,6 @@ assert_rolls_back(User) { CreateUser.call(email: "bad", name: "A") }
|
|
|
738
795
|
assert_commits(User) { CreateUser.call(email: "ok@b.com", name: "A") }
|
|
739
796
|
```
|
|
740
797
|
|
|
741
|
-
### Batch Assertions
|
|
742
|
-
|
|
743
|
-
```ruby
|
|
744
|
-
assert_all_succeed(params_list: [
|
|
745
|
-
{ email: "a@b.com", name: "A" },
|
|
746
|
-
{ email: "b@b.com", name: "B" }
|
|
747
|
-
])
|
|
748
|
-
|
|
749
|
-
assert_all_fail(code: :invalid_email, params_list: [
|
|
750
|
-
{ email: "", name: "A" },
|
|
751
|
-
{ email: "no-at", name: "B" }
|
|
752
|
-
])
|
|
753
|
-
# Also supports message: and details: options
|
|
754
|
-
```
|
|
755
|
-
|
|
756
798
|
### Stubbing
|
|
757
799
|
|
|
758
800
|
Replace an operation within a block. Bypasses all wrappers, not recorded in TestLog:
|
|
@@ -822,19 +864,9 @@ class CreateUserTest < Minitest::Test
|
|
|
822
864
|
assert_ok(result) { |user| assert_equal "Alice", user.name }
|
|
823
865
|
end
|
|
824
866
|
|
|
825
|
-
def test_one_liner
|
|
826
|
-
assert_operation(email: "a@b.com", name: "Alice")
|
|
827
|
-
end
|
|
828
|
-
|
|
829
867
|
def test_rejects_bad_email
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
def test_batch_rejects
|
|
834
|
-
assert_all_fail(code: :invalid_email, params_list: [
|
|
835
|
-
{ email: "", name: "A" },
|
|
836
|
-
{ email: "no-at", name: "B" }
|
|
837
|
-
])
|
|
868
|
+
result = call_operation(email: "bad", name: "A")
|
|
869
|
+
assert_err result, :invalid_email
|
|
838
870
|
end
|
|
839
871
|
|
|
840
872
|
def test_stubs_dependency
|
|
@@ -911,6 +943,8 @@ end
|
|
|
911
943
|
|
|
912
944
|
Requires `gem 'ruby_llm'` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
|
|
913
945
|
|
|
946
|
+
See `TOOL.md` for the full Tool reference including query tools.
|
|
947
|
+
|
|
914
948
|
---
|
|
915
949
|
|
|
916
950
|
**End of reference.**
|
data/guides/llm/QUERY.md
CHANGED
data/guides/llm/TOOL.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# Dex::Tool — LLM Reference
|
|
2
|
+
|
|
3
|
+
Install with `rake dex:guides` or copy manually to `AGENTS.md`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
`Dex::Tool` bridges Dex primitives to LLM tool calling via ruby-llm. It accepts Operation or Query classes and returns `RubyLLM::Tool` instances ready for `chat.with_tools(...)`.
|
|
10
|
+
|
|
11
|
+
Requires `gem "ruby_llm"` in your Gemfile. Lazy-loaded — ruby-llm is only required when you call `Dex::Tool`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Operation Tools
|
|
16
|
+
|
|
17
|
+
### Creating
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
tool = Dex::Tool.from(Orders::Place) # single operation
|
|
21
|
+
tools = Dex::Tool.all # all registered operations
|
|
22
|
+
tools = Dex::Tool.from_namespace("Orders") # operations under Orders::
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`from` accepts no options for Operation classes. `all` and `from_namespace` return sorted arrays.
|
|
26
|
+
|
|
27
|
+
### Tool Schema
|
|
28
|
+
|
|
29
|
+
The tool name is derived from the class name: `Orders::Place` becomes `dex_orders_place`.
|
|
30
|
+
|
|
31
|
+
The description includes:
|
|
32
|
+
- The operation's `description` (or class name if none)
|
|
33
|
+
- Guard preconditions (if any)
|
|
34
|
+
- Declared error codes (if any)
|
|
35
|
+
|
|
36
|
+
The params schema is the operation's `contract.to_json_schema`.
|
|
37
|
+
|
|
38
|
+
### Execution Flow
|
|
39
|
+
|
|
40
|
+
1. Params are symbolized
|
|
41
|
+
2. Operation is instantiated with params
|
|
42
|
+
3. Called via `.safe.call` (returns `Ok` or `Err`, never raises)
|
|
43
|
+
4. `Ok` — returns `value.as_json` (or raw value if no `as_json`)
|
|
44
|
+
5. `Err` — returns `{ error:, message:, details: }`
|
|
45
|
+
|
|
46
|
+
### Example
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
class Orders::Place < Dex::Operation
|
|
50
|
+
description "Place a new order for a customer"
|
|
51
|
+
|
|
52
|
+
prop :customer_id, _Ref(Customer)
|
|
53
|
+
prop :product_id, _Ref(Product)
|
|
54
|
+
prop? :quantity, Integer, default: 1
|
|
55
|
+
|
|
56
|
+
error :out_of_stock, :invalid_quantity
|
|
57
|
+
|
|
58
|
+
guard :sufficient_stock, "Product must be in stock" do
|
|
59
|
+
Product.find(product_id).stock >= quantity
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def perform
|
|
63
|
+
error!(:invalid_quantity) if quantity <= 0
|
|
64
|
+
Order.create!(customer_id: customer_id, product_id: product_id, quantity: quantity)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
tool = Dex::Tool.from(Orders::Place)
|
|
69
|
+
chat = RubyLLM.chat.with_tools(tool)
|
|
70
|
+
chat.ask("Place an order for customer #12, product #42, quantity 3")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Error Shape
|
|
74
|
+
|
|
75
|
+
When an operation returns `Err`, the tool returns:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
{ error: :out_of_stock, message: "out_of_stock", details: nil }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Explain Tool
|
|
84
|
+
|
|
85
|
+
A meta-tool that checks whether an operation can execute with given params, without running it:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
tool = Dex::Tool.explain_tool
|
|
89
|
+
chat = RubyLLM.chat.with_tools(tool)
|
|
90
|
+
chat.ask("Can I place an order for product #42?")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The LLM calls it with `{ operation: "Orders::Place", params: { product_id: 42 } }`. Returns:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
{ callable: true, guards: [{ name: :sufficient_stock, passed: true }], once: nil, lock: nil }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If the operation is not in the registry: `{ error: "unknown_operation", message: "..." }`.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Query Tools
|
|
104
|
+
|
|
105
|
+
### Creating
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
tool = Dex::Tool.from(Product::Query,
|
|
109
|
+
scope: -> { Current.user.products },
|
|
110
|
+
serialize: ->(record) { record.as_json(only: %i[id name price stock]) })
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Required Options
|
|
114
|
+
|
|
115
|
+
Both `scope:` and `serialize:` are mandatory for Query tools.
|
|
116
|
+
|
|
117
|
+
**`scope:`** — a lambda returning the base relation. Called at execution time:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
Dex::Tool.from(Order::Query,
|
|
121
|
+
scope: -> { Current.user.orders },
|
|
122
|
+
serialize: ->(r) { r.as_json })
|
|
123
|
+
|
|
124
|
+
Dex::Tool.from(Product::Query,
|
|
125
|
+
scope: -> { Product.where(active: true) },
|
|
126
|
+
serialize: ->(r) { r.as_json })
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**`serialize:`** — a lambda converting each record to a hash:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
Dex::Tool.from(Product::Query,
|
|
133
|
+
scope: -> { Product.all },
|
|
134
|
+
serialize: ->(r) { r.as_json(only: %i[id name price]) })
|
|
135
|
+
|
|
136
|
+
Dex::Tool.from(Order::Query,
|
|
137
|
+
scope: -> { Current.user.orders },
|
|
138
|
+
serialize: ->(r) { { id: r.id, total: r.total, status: r.status } })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Optional Restrictions
|
|
142
|
+
|
|
143
|
+
**`limit:`** — max results per page (default: 50). The LLM can request fewer but never more:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
Dex::Tool.from(Product::Query, scope: -> { Product.all }, serialize: ->(r) { r.as_json }, limit: 25)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**`only_filters:`** — allowlist of filters exposed to the LLM:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
Dex::Tool.from(Product::Query,
|
|
153
|
+
scope: -> { Product.all },
|
|
154
|
+
serialize: ->(r) { r.as_json },
|
|
155
|
+
only_filters: %i[name category])
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**`except_filters:`** — denylist of filters hidden from the LLM (mutually exclusive with `only_filters:`):
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
Dex::Tool.from(Product::Query,
|
|
162
|
+
scope: -> { Product.all },
|
|
163
|
+
serialize: ->(r) { r.as_json },
|
|
164
|
+
except_filters: %i[internal_code])
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**`only_sorts:`** — allowlist of sort columns. Must include the query's default sort if one exists:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
Dex::Tool.from(Product::Query,
|
|
171
|
+
scope: -> { Product.all },
|
|
172
|
+
serialize: ->(r) { r.as_json },
|
|
173
|
+
only_sorts: %i[name price created_at])
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Auto-Exclusions
|
|
177
|
+
|
|
178
|
+
These props are automatically excluded from the tool schema (the LLM never sees them):
|
|
179
|
+
|
|
180
|
+
- Props mapped via `context` (filled from ambient context)
|
|
181
|
+
- `_Ref` typed props (model references)
|
|
182
|
+
- Props for hidden filters (via `only_filters:` / `except_filters:`)
|
|
183
|
+
|
|
184
|
+
If an auto-excluded prop is required with no default and no context mapping, `from` raises `ArgumentError` at build time.
|
|
185
|
+
|
|
186
|
+
### Tool Schema
|
|
187
|
+
|
|
188
|
+
The tool name is `dex_query_{class_name}` (lowercased, `::` replaced with `_`).
|
|
189
|
+
|
|
190
|
+
The description includes:
|
|
191
|
+
- The query's `description` (or class name)
|
|
192
|
+
- Available filters with type hints and enum values
|
|
193
|
+
- Available sorts with default indicator
|
|
194
|
+
- Max results per page
|
|
195
|
+
|
|
196
|
+
The params schema includes visible filter props plus:
|
|
197
|
+
|
|
198
|
+
- `sort` — enum of allowed sort values (prefix with `-` for descending; custom sorts have no `-` variant)
|
|
199
|
+
- `limit` — integer, max results
|
|
200
|
+
- `offset` — integer, skip N results (for pagination)
|
|
201
|
+
|
|
202
|
+
### Execution Flow
|
|
203
|
+
|
|
204
|
+
1. Extract `limit`, `offset`, `sort` from params
|
|
205
|
+
2. Clamp `limit` to max (default 50); zero or negative resets to max
|
|
206
|
+
3. Floor `offset` at 0
|
|
207
|
+
4. Validate sort value against allowed sorts; drop invalid (falls back to query default)
|
|
208
|
+
5. Custom sorts reject `-` prefix (direction is baked into the block)
|
|
209
|
+
6. Strip context-mapped and excluded filter params
|
|
210
|
+
7. Inject scope from `scope:` lambda
|
|
211
|
+
8. Build query via `from_params` (coercion, blank stripping, validation)
|
|
212
|
+
9. Resolve, count total, apply offset/limit
|
|
213
|
+
10. Serialize each record via `serialize:` lambda
|
|
214
|
+
|
|
215
|
+
### Return Shape
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"records": [{ "id": 1, "name": "Widget", "price": 9.99 }],
|
|
220
|
+
"total": 142,
|
|
221
|
+
"limit": 50,
|
|
222
|
+
"offset": 0
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`total` is `nil` if the count query fails (e.g., complex GROUP BY).
|
|
227
|
+
|
|
228
|
+
### Error Handling
|
|
229
|
+
|
|
230
|
+
Invalid params or type errors:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
{ error: "invalid_params", message: "..." }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Any other error:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
{ error: "query_failed", message: "..." }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Context
|
|
245
|
+
|
|
246
|
+
`Dex.with_context` provides ambient values to both Operation and Query tools. Props with `context` mappings are auto-filled and hidden from the LLM:
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
class Orders::Place < Dex::Operation
|
|
250
|
+
prop :customer_id, _Ref(Customer)
|
|
251
|
+
context customer_id: :current_customer_id
|
|
252
|
+
# ...
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
class Order::Query < Dex::Query
|
|
256
|
+
scope { Order.all }
|
|
257
|
+
prop? :customer_id, _Ref(Customer)
|
|
258
|
+
context customer_id: :current_customer_id
|
|
259
|
+
filter :customer_id
|
|
260
|
+
# ...
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
Dex.with_context(current_customer_id: current_user.customer_id) do
|
|
264
|
+
chat.ask("Show me my recent orders")
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The LLM never sees `customer_id` in either tool's schema — it is injected from context.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Security Model
|
|
273
|
+
|
|
274
|
+
Five layers protect against misuse:
|
|
275
|
+
|
|
276
|
+
1. **Scope lambda** — called at execution time, applies authorization (`Current.user.orders`, `policy_scope(...)`)
|
|
277
|
+
2. **Context injection** — security-sensitive props (tenant, user) are filled from ambient context, invisible to the LLM
|
|
278
|
+
3. **Filter restrictions** — `only_filters:` / `except_filters:` control what the LLM can search on
|
|
279
|
+
4. **Sort restrictions** — `only_sorts:` limits available sort columns
|
|
280
|
+
5. **Limit cap** — `limit:` sets a hard ceiling on results per page; the LLM cannot exceed it
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Combining Operation + Query Tools
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
order_tools = Dex::Tool.from_namespace("Orders")
|
|
288
|
+
|
|
289
|
+
search_tool = Dex::Tool.from(Product::Query,
|
|
290
|
+
scope: -> { Current.user.products },
|
|
291
|
+
serialize: ->(r) { r.as_json(only: %i[id name price stock]) },
|
|
292
|
+
limit: 20,
|
|
293
|
+
only_filters: %i[name category],
|
|
294
|
+
only_sorts: %i[name price])
|
|
295
|
+
|
|
296
|
+
explain = Dex::Tool.explain_tool
|
|
297
|
+
|
|
298
|
+
chat = RubyLLM.chat
|
|
299
|
+
chat.with_tools(*order_tools, search_tool, explain)
|
|
300
|
+
|
|
301
|
+
Dex.with_context(current_customer_id: current_user.customer_id) do
|
|
302
|
+
chat.ask("Find products under $50, then place an order for the cheapest one")
|
|
303
|
+
end
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
**End of reference.**
|
data/lib/dex/event/bus.rb
CHANGED
|
@@ -89,15 +89,13 @@ module Dex
|
|
|
89
89
|
|
|
90
90
|
def enqueue(handler_class, event, trace_data)
|
|
91
91
|
ensure_active_job_loaded!
|
|
92
|
-
ctx = event.context
|
|
93
92
|
|
|
94
93
|
Dex::Event::Processor.perform_later(
|
|
95
94
|
handler_class: handler_class.name,
|
|
96
95
|
event_class: event.class.name,
|
|
97
96
|
payload: event._props_as_json,
|
|
98
97
|
metadata: event.metadata.as_json,
|
|
99
|
-
trace: trace_data
|
|
100
|
-
context: ctx
|
|
98
|
+
trace: trace_data
|
|
101
99
|
)
|
|
102
100
|
end
|
|
103
101
|
|
data/lib/dex/event/handler.rb
CHANGED
|
@@ -113,8 +113,7 @@ module Dex
|
|
|
113
113
|
timestamp: Time.parse(metadata_hash["timestamp"]),
|
|
114
114
|
trace_id: metadata_hash["trace_id"],
|
|
115
115
|
caused_by_id: metadata_hash["caused_by_id"],
|
|
116
|
-
event_ancestry: metadata_hash["event_ancestry"] || []
|
|
117
|
-
context: metadata_hash["context"]
|
|
116
|
+
event_ancestry: metadata_hash["event_ancestry"] || []
|
|
118
117
|
)
|
|
119
118
|
instance.instance_variable_set(:@metadata, metadata)
|
|
120
119
|
instance.freeze
|
data/lib/dex/event/metadata.rb
CHANGED
|
@@ -3,15 +3,14 @@
|
|
|
3
3
|
module Dex
|
|
4
4
|
class Event
|
|
5
5
|
class Metadata
|
|
6
|
-
attr_reader :id, :timestamp, :trace_id, :caused_by_id, :event_ancestry
|
|
6
|
+
attr_reader :id, :timestamp, :trace_id, :caused_by_id, :event_ancestry
|
|
7
7
|
|
|
8
|
-
def initialize(id:, timestamp:, trace_id:, caused_by_id:, event_ancestry
|
|
8
|
+
def initialize(id:, timestamp:, trace_id:, caused_by_id:, event_ancestry:)
|
|
9
9
|
@id = id
|
|
10
10
|
@timestamp = timestamp
|
|
11
11
|
@trace_id = trace_id
|
|
12
12
|
@caused_by_id = caused_by_id
|
|
13
13
|
@event_ancestry = event_ancestry
|
|
14
|
-
@context = context
|
|
15
14
|
freeze
|
|
16
15
|
end
|
|
17
16
|
|
|
@@ -26,22 +25,12 @@ module Dex
|
|
|
26
25
|
[]
|
|
27
26
|
end
|
|
28
27
|
|
|
29
|
-
ctx = if Dex.configuration.event_context
|
|
30
|
-
begin
|
|
31
|
-
Dex.configuration.event_context.call
|
|
32
|
-
rescue => e
|
|
33
|
-
Event._warn("event_context failed: #{e.message}")
|
|
34
|
-
nil
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
28
|
new(
|
|
39
29
|
id: id,
|
|
40
30
|
timestamp: Time.now.utc,
|
|
41
31
|
trace_id: trace_id,
|
|
42
32
|
caused_by_id: caused,
|
|
43
|
-
event_ancestry: ancestry
|
|
44
|
-
context: ctx
|
|
33
|
+
event_ancestry: ancestry
|
|
45
34
|
)
|
|
46
35
|
end
|
|
47
36
|
|
|
@@ -53,7 +42,6 @@ module Dex
|
|
|
53
42
|
"event_ancestry" => @event_ancestry
|
|
54
43
|
}
|
|
55
44
|
h["caused_by_id"] = @caused_by_id if @caused_by_id
|
|
56
|
-
h["context"] = @context if @context
|
|
57
45
|
h
|
|
58
46
|
end
|
|
59
47
|
end
|
data/lib/dex/event/processor.rb
CHANGED
|
@@ -7,9 +7,7 @@ module Dex
|
|
|
7
7
|
return super unless name == :Processor && defined?(ActiveJob::Base)
|
|
8
8
|
|
|
9
9
|
const_set(:Processor, Class.new(ActiveJob::Base) do
|
|
10
|
-
def perform(handler_class:, event_class:, payload:, metadata:, trace: nil,
|
|
11
|
-
restore_context(context)
|
|
12
|
-
|
|
10
|
+
def perform(handler_class:, event_class:, payload:, metadata:, trace: nil, attempt_number: 1)
|
|
13
11
|
handler = Object.const_get(handler_class)
|
|
14
12
|
retry_config = handler._event_handler_retry_config
|
|
15
13
|
|
|
@@ -25,7 +23,6 @@ module Dex
|
|
|
25
23
|
payload: payload,
|
|
26
24
|
metadata: metadata,
|
|
27
25
|
trace: trace,
|
|
28
|
-
context: context,
|
|
29
26
|
attempt_number: attempt_number + 1
|
|
30
27
|
)
|
|
31
28
|
else
|
|
@@ -35,17 +32,6 @@ module Dex
|
|
|
35
32
|
|
|
36
33
|
private
|
|
37
34
|
|
|
38
|
-
def restore_context(context)
|
|
39
|
-
return unless context
|
|
40
|
-
|
|
41
|
-
restorer = Dex.configuration.restore_event_context
|
|
42
|
-
return unless restorer
|
|
43
|
-
|
|
44
|
-
restorer.call(context)
|
|
45
|
-
rescue => e
|
|
46
|
-
Dex::Event._warn("restore_event_context failed: #{e.message}")
|
|
47
|
-
end
|
|
48
|
-
|
|
49
35
|
def compute_delay(config, attempt)
|
|
50
36
|
wait = config[:wait]
|
|
51
37
|
case wait
|
data/lib/dex/event.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
# Modules loaded before class body (no reference to Dex::Event needed)
|
|
4
4
|
require_relative "event/execution_state"
|
|
5
5
|
require_relative "event/metadata"
|
|
6
|
-
require_relative "event/trace"
|
|
7
6
|
require_relative "event/suppression"
|
|
8
7
|
|
|
9
8
|
module Dex
|
|
@@ -68,7 +67,6 @@ module Dex
|
|
|
68
67
|
def trace_id = metadata.trace_id
|
|
69
68
|
def caused_by_id = metadata.caused_by_id
|
|
70
69
|
def event_ancestry = metadata.event_ancestry
|
|
71
|
-
def context = metadata.context
|
|
72
70
|
|
|
73
71
|
# Publishing
|
|
74
72
|
def publish(sync: false)
|
|
@@ -77,7 +75,7 @@ module Dex
|
|
|
77
75
|
|
|
78
76
|
def self.publish(sync: false, caused_by: nil, **kwargs)
|
|
79
77
|
if caused_by
|
|
80
|
-
Trace.
|
|
78
|
+
Dex::Trace.with_event_context(caused_by) do
|
|
81
79
|
new(**kwargs).publish(sync: sync)
|
|
82
80
|
end
|
|
83
81
|
else
|