ruby_llm-dagcache 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +127 -0
- data/lib/ruby_llm/dagcache/agent.rb +45 -0
- data/lib/ruby_llm/dagcache/bindings.rb +132 -0
- data/lib/ruby_llm/dagcache/configuration.rb +28 -0
- data/lib/ruby_llm/dagcache/graph.rb +142 -0
- data/lib/ruby_llm/dagcache/keys.rb +57 -0
- data/lib/ruby_llm/dagcache/recorder.rb +57 -0
- data/lib/ruby_llm/dagcache/replay.rb +132 -0
- data/lib/ruby_llm/dagcache/store.rb +129 -0
- data/lib/ruby_llm/dagcache/tool_patch.rb +57 -0
- data/lib/ruby_llm/dagcache/version.rb +7 -0
- data/lib/ruby_llm/dagcache.rb +142 -0
- metadata +56 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 38c5ecebec52654cc6bcab8a52691c2d6aaa893911efaa1d053ca996c65ccdd1
|
|
4
|
+
data.tar.gz: bdd1dc7f7ceca174ca0b0976075876421b3048765e3b6ba8d4dc654950c1d03a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6ee32b813383fc93cdcef9f38c97711ee38ffe9ca1b2c62dc7f575a2a611ac4d282f1a844a3dcdaa7ba636fcd3fa2c90a60f5af4aac5f5036923960963cdd796
|
|
7
|
+
data.tar.gz: 287796a0e8f2a84d415ad59d7760d7d3a6021944c590982420bd5a357c909d1345b05dc1a719a4d330bca99d17ff088af8a39356db783324285ce96dd8e76c60
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raj Mirpuri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# 📼 ruby_llm-dagcache
|
|
2
|
+
|
|
3
|
+
> **VCR cassettes for [RubyLLM](https://github.com/crmne/ruby_llm) agents** —
|
|
4
|
+
> record what an agent did once, replay it the next time the same kind of
|
|
5
|
+
> task comes in. The LLM only runs when something genuinely new happens.
|
|
6
|
+
|
|
7
|
+
[](https://www.ruby-lang.org/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://github.com/crmne/ruby_llm)
|
|
10
|
+
[](https://github.com/itstheraj/dag_cache)
|
|
11
|
+
|
|
12
|
+
Same idea as the Python library
|
|
13
|
+
**[`dagcache`](https://github.com/itstheraj/dag_cache)**, and the same
|
|
14
|
+
record-and-replay philosophy as [VCR](https://github.com/vcr/vcr) — but for
|
|
15
|
+
agent runs instead of HTTP calls.
|
|
16
|
+
|
|
17
|
+
## 🎬 Demo
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
Run it yourself — no API keys needed:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
ruby examples/demo.rb
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 🚀 Usage
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "ruby_llm" # the real gem
|
|
31
|
+
require "ruby_llm/dagcache"
|
|
32
|
+
|
|
33
|
+
RubyLLM::DagCache.configure do |c|
|
|
34
|
+
c.store_path = ".dagcache" # one YAML cassette per run
|
|
35
|
+
c.replay_mode = :verified # or :frozen (VCR mode, nothing executes)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Weather < RubyLLM::Tool
|
|
39
|
+
description "Get current weather"
|
|
40
|
+
def execute(latitude:, longitude:) = WeatherAPI.current(latitude, longitude)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Refund < RubyLLM::Tool
|
|
44
|
+
def self.dagcache_effectful? = true # has side effects: re-run on replay, keep order
|
|
45
|
+
def execute(order_id:) = Payment.refund(order_id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
agent = RubyLLM::DagCache.watch(MyAgent.new, key: ->(msg) { classify(msg) })
|
|
49
|
+
agent.ask("refund order O-123") # 1st call: live + record
|
|
50
|
+
agent.ask("refund order O-456") # 2nd call: replay, no LLM planning
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Good to know:**
|
|
54
|
+
|
|
55
|
+
- 🔧 `RubyLLM::Tool` subclasses are picked up **automatically**. An
|
|
56
|
+
`inherited` hook adds the recorder to each tool class (a plain prepend on
|
|
57
|
+
`RubyLLM::Tool` wouldn't work — subclass `execute` methods would shadow it).
|
|
58
|
+
- 👀 `watch` wraps anything with an `#ask` method (an Agent, a Chat, your own
|
|
59
|
+
class). Everything else passes through untouched.
|
|
60
|
+
- 🗝️ `key:` tells runs apart — its return value goes into the cache key, so
|
|
61
|
+
"refund" tasks and "weather" tasks get separate caches. Without it, all
|
|
62
|
+
string prompts share one cache.
|
|
63
|
+
|
|
64
|
+
Not using RubyLLM, or want more control? Use the DSL:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
search = RubyLLM::DagCache.tool("search_kb", pure: true) { |query:| KB.search(query) }
|
|
68
|
+
plan = RubyLLM::DagCache.llm("planner", planning: true) { |prompt| chat.ask(prompt).content }
|
|
69
|
+
draft = RubyLLM::DagCache.llm("draft") { |prompt| chat.ask(prompt).content }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 🧠 How it works
|
|
73
|
+
|
|
74
|
+
Same design as [the Python side](https://github.com/itstheraj/dag_cache):
|
|
75
|
+
|
|
76
|
+
- **The cache key is the chain of calls, not the arguments.** Two runs match
|
|
77
|
+
when they make the same calls in the same shape — the actual values don't
|
|
78
|
+
matter.
|
|
79
|
+
- **Arguments are slots that get re-filled at replay.** Each one is either an
|
|
80
|
+
`input` (from the request), a `node` output (from an earlier call), or a
|
|
81
|
+
`literal` (written by the LLM).
|
|
82
|
+
- **Replay is verified, not blind.** Tools run again for real (fresh data,
|
|
83
|
+
real side effects), planning LLM calls are skipped, and output LLM calls
|
|
84
|
+
re-run with prompts updated to the fresh values. If anything doesn't line
|
|
85
|
+
up — shape changed, a value can't be resolved, a tool fails — the live
|
|
86
|
+
agent takes over automatically.
|
|
87
|
+
- **Cassettes are plain YAML** in `.dagcache/`, one per run, meant to be
|
|
88
|
+
reviewed in git. They move `staging` → `approved` (the canonical path) →
|
|
89
|
+
`dead` (failed too often). If several paths fit one task, the one with the
|
|
90
|
+
most hits and recordings wins; an approved path always wins.
|
|
91
|
+
|
|
92
|
+
## ⚙️ Configuration
|
|
93
|
+
|
|
94
|
+
| Setting | Default | Env |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `store_path` | `.dagcache` | `DAGCACHE_STORE` |
|
|
97
|
+
| `replay_mode` | `:verified` | `DAGCACHE_REPLAY` |
|
|
98
|
+
| `enabled` / `force_record` | on | `DAGCACHE_MODE=off\|record` |
|
|
99
|
+
| `auto_replay` | `true` (staging replays too) | — |
|
|
100
|
+
| `fallback_demote_threshold` | `3` | — |
|
|
101
|
+
| `default_ttl_seconds` | `nil` | — |
|
|
102
|
+
|
|
103
|
+
## ⚠️ Limitations
|
|
104
|
+
|
|
105
|
+
Same honest list as [the Python library](https://github.com/itstheraj/dag_cache):
|
|
106
|
+
matching is exact (fuzzy matching is dangerous with side-effecting tools),
|
|
107
|
+
replay re-runs side effects, LLM-written literals can go stale, prompt
|
|
108
|
+
patching is a heuristic, and the watched `ask` should return the result of
|
|
109
|
+
its final LLM/tool call.
|
|
110
|
+
|
|
111
|
+
## 🧪 Tests
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
ruby -Ilib -Itest test/test_end_to_end.rb # or: rake test
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The suite uses a fake `RubyLLM::Tool` — no API keys required.
|
|
118
|
+
|
|
119
|
+
## 🔗 Related projects
|
|
120
|
+
|
|
121
|
+
- [**dagcache**](https://github.com/itstheraj/dag_cache) — the Python original; same idea, same cassettes.
|
|
122
|
+
- [**RubyLLM**](https://github.com/crmne/ruby_llm) — the Ruby LLM library this gem plugs into.
|
|
123
|
+
- [**VCR**](https://github.com/vcr/vcr) — the record-and-replay HTTP library that inspired this.
|
|
124
|
+
|
|
125
|
+
## 📄 License
|
|
126
|
+
|
|
127
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module DagCache
|
|
5
|
+
# Watch any object responding to +#ask+ (a RubyLLM::Agent, a Chat, or
|
|
6
|
+
# your own wrapper). Returns a delegating wrapper; everything else
|
|
7
|
+
# passes through to the underlying agent.
|
|
8
|
+
#
|
|
9
|
+
# agent = RubyLLM::DagCache.watch(SupportAgent.new, key: ->(msg) { classify(msg) })
|
|
10
|
+
# agent.ask("refund my order O-123")
|
|
11
|
+
#
|
|
12
|
+
# +key:+ classifies messages into task kinds -- its return *value* is
|
|
13
|
+
# mixed into the fingerprint, keeping "refund" and "weather" requests
|
|
14
|
+
# (same shape!) in separate path caches.
|
|
15
|
+
module Agent
|
|
16
|
+
def self.watch(agent, kind: nil, key: nil)
|
|
17
|
+
Wrapper.new(agent, kind || agent.class.name, key)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Delegating wrapper around an agent.
|
|
21
|
+
class Wrapper
|
|
22
|
+
def initialize(agent, kind, key_fn)
|
|
23
|
+
@agent = agent
|
|
24
|
+
@kind = kind
|
|
25
|
+
@key_fn = key_fn
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ask(message, **kwargs, &block)
|
|
29
|
+
extra = @key_fn&.call(message)
|
|
30
|
+
DagCache.run_cached(task_kind: @kind, inputs: { "message" => message }, key: extra) do
|
|
31
|
+
@agent.ask(message, **kwargs, &block)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def method_missing(name, ...)
|
|
36
|
+
@agent.send(name, ...)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def respond_to_missing?(name, include_private = false)
|
|
40
|
+
@agent.respond_to?(name, include_private) || super
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module DagCache
|
|
5
|
+
# Turn recorded literal arguments into re-bindable slots, and back.
|
|
6
|
+
#
|
|
7
|
+
# At record time we infer the *provenance* of every tool argument:
|
|
8
|
+
# straight from the agent's input? From an upstream tool's output? Or
|
|
9
|
+
# did the LLM make it up (literal)? At replay time bindings resolve
|
|
10
|
+
# against the *new* inputs and *fresh* upstream outputs -- which is how
|
|
11
|
+
# a cached path runs on data it has never seen.
|
|
12
|
+
module Bindings
|
|
13
|
+
class BindingError < StandardError; end
|
|
14
|
+
|
|
15
|
+
MAX_DEPTH = 12
|
|
16
|
+
MAX_MATCHES = 8
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Values too generic to safely provenance-match stay literal.
|
|
21
|
+
def worth_matching?(value)
|
|
22
|
+
case value
|
|
23
|
+
when NilClass, TrueClass, FalseClass then false
|
|
24
|
+
when Numeric then value.abs >= 100
|
|
25
|
+
when String then value.length >= 2
|
|
26
|
+
else true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def walk(root, path)
|
|
31
|
+
path.reduce(root) do |cur, seg|
|
|
32
|
+
case cur
|
|
33
|
+
when Hash
|
|
34
|
+
key = [seg, seg.to_s, (seg.to_sym if seg.respond_to?(:to_sym))].compact.find { |k| cur.key?(k) }
|
|
35
|
+
raise BindingError, "missing key #{seg.inspect} in #{cur.inspect}" if key.nil?
|
|
36
|
+
|
|
37
|
+
cur[key]
|
|
38
|
+
when Array
|
|
39
|
+
begin
|
|
40
|
+
cur[Integer(seg)]
|
|
41
|
+
rescue ArgumentError, TypeError
|
|
42
|
+
raise BindingError, "missing index #{seg.inspect}"
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
attr = seg.to_s
|
|
46
|
+
if cur.instance_variable_defined?(:"@#{attr}")
|
|
47
|
+
cur.instance_variable_get(:"@#{attr}")
|
|
48
|
+
elsif cur.respond_to?(attr)
|
|
49
|
+
cur.public_send(attr)
|
|
50
|
+
else
|
|
51
|
+
raise BindingError, "missing attribute #{attr.inspect} on #{cur.class}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Collect paths under +root+ whose value deep-equals +value+.
|
|
58
|
+
def find(value, root, base, out, depth = 0)
|
|
59
|
+
return if depth > MAX_DEPTH || out.size > MAX_MATCHES
|
|
60
|
+
|
|
61
|
+
if root.class == value.class && root == value
|
|
62
|
+
out << base
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
case root
|
|
66
|
+
when Hash
|
|
67
|
+
root.each { |k, v| find(value, v, base + [k.to_s], out, depth + 1) }
|
|
68
|
+
when Array
|
|
69
|
+
root.each_with_index { |v, i| find(value, v, base + [i], out, depth + 1) }
|
|
70
|
+
else
|
|
71
|
+
root.instance_variables.reject { |iv| iv.to_s.start_with?("@_") }.each do |iv|
|
|
72
|
+
find(value, root.instance_variable_get(iv), base + [iv.to_s.delete_prefix("@")], out, depth + 1)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Infer where a recorded argument value came from. Inputs win over
|
|
78
|
+
# node outputs; earliest nodes win over later ones.
|
|
79
|
+
def infer_binding(value, inputs, prior)
|
|
80
|
+
return Binding.new(source: "literal", value: jsonable(value)) unless worth_matching?(value)
|
|
81
|
+
|
|
82
|
+
found = []
|
|
83
|
+
find(value, inputs, [], found)
|
|
84
|
+
return Binding.new(source: "input", path: found.min_by(&:size)) if found.any?
|
|
85
|
+
|
|
86
|
+
prior.each do |node_id, output|
|
|
87
|
+
found = []
|
|
88
|
+
find(value, output, [], found)
|
|
89
|
+
return Binding.new(source: "node", path: [node_id] + found.min_by(&:size)) if found.any?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Binding.new(source: "literal", value: jsonable(value))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Resolve a binding against this call's inputs and fresh outputs.
|
|
96
|
+
def resolve(binding, inputs, outputs)
|
|
97
|
+
case binding.source
|
|
98
|
+
when "literal" then binding.value
|
|
99
|
+
when "input"
|
|
100
|
+
walk(inputs, binding.path)
|
|
101
|
+
when "node"
|
|
102
|
+
node_id = binding.path.first
|
|
103
|
+
raise BindingError, "node binding references unavailable node #{binding.path.inspect}" unless outputs.key?(node_id)
|
|
104
|
+
|
|
105
|
+
walk(outputs[node_id], binding.path[1..])
|
|
106
|
+
else
|
|
107
|
+
raise BindingError, "unknown binding source #{binding.source.inspect}"
|
|
108
|
+
end
|
|
109
|
+
rescue BindingError => e
|
|
110
|
+
raise BindingError, "#{binding.source} binding #{binding.path.inspect}: #{e.message}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Best-effort conversion to a YAML/JSON-serializable value.
|
|
114
|
+
def jsonable(value)
|
|
115
|
+
case value
|
|
116
|
+
when NilClass, TrueClass, FalseClass, Numeric, String then value
|
|
117
|
+
when Hash then value.to_h { |k, v| [k.to_s, jsonable(v)] }
|
|
118
|
+
when Array then value.map { |v| jsonable(v) }
|
|
119
|
+
else
|
|
120
|
+
ivars = value.instance_variables.reject { |iv| iv.to_s.start_with?("@_") }
|
|
121
|
+
if ivars.any?
|
|
122
|
+
{ "__class__" => value.class.name }.merge(
|
|
123
|
+
ivars.to_h { |iv| [iv.to_s.delete_prefix("@"), jsonable(value.instance_variable_get(iv))] }
|
|
124
|
+
)
|
|
125
|
+
else
|
|
126
|
+
value.inspect
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module DagCache
|
|
5
|
+
# Global configuration. Values mirror the Python library's policy.
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :store_path, :enabled, :force_record, :replay_mode,
|
|
8
|
+
:auto_replay, :fallback_demote_threshold, :default_ttl_seconds
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@store_path = ENV.fetch("DAGCACHE_STORE", ".dagcache")
|
|
12
|
+
@enabled = ENV["DAGCACHE_MODE"] != "off"
|
|
13
|
+
@force_record = ENV["DAGCACHE_MODE"] == "record"
|
|
14
|
+
@replay_mode = ENV.fetch("DAGCACHE_REPLAY", "verified").to_sym # :verified | :frozen
|
|
15
|
+
@auto_replay = true # replay staging DAGs, not only approved ones
|
|
16
|
+
@fallback_demote_threshold = 3
|
|
17
|
+
@default_ttl_seconds = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def replay_mode=(value)
|
|
21
|
+
value = value.to_sym
|
|
22
|
+
raise ArgumentError, "replay_mode must be :verified or :frozen" unless %i[verified frozen].include?(value)
|
|
23
|
+
|
|
24
|
+
@replay_mode = value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module DagCache
|
|
5
|
+
# How to obtain one argument value at replay time.
|
|
6
|
+
# source "input": walk the agent's call arguments along +path+
|
|
7
|
+
# source "node": walk a prior node's (fresh) output; path[0] is the
|
|
8
|
+
# upstream node id
|
|
9
|
+
# source "literal": the LLM synthesized this; reuse recorded +value+
|
|
10
|
+
class Binding
|
|
11
|
+
attr_accessor :source, :path, :value
|
|
12
|
+
|
|
13
|
+
def initialize(source:, path: [], value: nil)
|
|
14
|
+
@source = source
|
|
15
|
+
@path = path
|
|
16
|
+
@value = value
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
{ "source" => @source, "path" => @path, "value" => @value }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.from_h(hash)
|
|
24
|
+
new(source: hash["source"], path: hash["path"] || [], value: hash["value"])
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# One step of a recorded run. Luigi-flavored: +requires+ lists upstream
|
|
29
|
+
# node ids, +recorded_output+ is the Luigi "output" that lets frozen
|
|
30
|
+
# replay skip execution.
|
|
31
|
+
class Node
|
|
32
|
+
attr_accessor :id, :kind, :name, :purity, :args, :recorded_output,
|
|
33
|
+
:output_shape, :requires, :duration_ms
|
|
34
|
+
|
|
35
|
+
def initialize(id:, kind:, name:, purity:, args: {}, recorded_output: nil,
|
|
36
|
+
output_shape: nil, requires: [], duration_ms: 0.0)
|
|
37
|
+
@id = id
|
|
38
|
+
@kind = kind # "tool" | "llm_plan" | "llm_output"
|
|
39
|
+
@name = name
|
|
40
|
+
@purity = purity # "pure" | "effectful" | "llm"
|
|
41
|
+
@args = args # String name => Binding
|
|
42
|
+
@recorded_output = recorded_output
|
|
43
|
+
@output_shape = output_shape
|
|
44
|
+
@requires = requires
|
|
45
|
+
@duration_ms = duration_ms
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{
|
|
50
|
+
"id" => @id, "kind" => @kind, "name" => @name, "purity" => @purity,
|
|
51
|
+
"args" => @args.transform_values(&:to_h),
|
|
52
|
+
"recorded_output" => @recorded_output, "output_shape" => @output_shape,
|
|
53
|
+
"requires" => @requires, "duration_ms" => @duration_ms
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.from_h(hash)
|
|
58
|
+
new(
|
|
59
|
+
id: hash["id"], kind: hash["kind"], name: hash["name"], purity: hash["purity"],
|
|
60
|
+
args: (hash["args"] || {}).transform_values { |v| Binding.from_h(v) },
|
|
61
|
+
recorded_output: hash["recorded_output"], output_shape: hash["output_shape"],
|
|
62
|
+
requires: hash["requires"] || [], duration_ms: hash["duration_ms"] || 0.0
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The cached artifact: an ordered DAG of nodes. +id+/+status+/stats are
|
|
68
|
+
# store-managed metadata, not part of the artifact.
|
|
69
|
+
class DAG
|
|
70
|
+
attr_accessor :task_kind, :fingerprint, :nodes, :id, :status,
|
|
71
|
+
:recordings, :hits, :fallbacks, :created_at, :ttl_seconds
|
|
72
|
+
|
|
73
|
+
def initialize(task_kind:, fingerprint:, nodes: [])
|
|
74
|
+
@task_kind = task_kind
|
|
75
|
+
@fingerprint = fingerprint
|
|
76
|
+
@nodes = nodes
|
|
77
|
+
@id = nil
|
|
78
|
+
@status = "staging" # staging | approved | dead
|
|
79
|
+
@recordings = 1
|
|
80
|
+
@hits = 0
|
|
81
|
+
@fallbacks = 0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The canonical tool chain -- this is what we cache *on*.
|
|
85
|
+
def path
|
|
86
|
+
@nodes.select { |n| n.kind == "tool" }.map(&:name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def path_key
|
|
90
|
+
Keys.path_key(path)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def terminal
|
|
94
|
+
@nodes.last or raise "DAG has no nodes"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Kahn's algorithm with original index as tiebreak (stable order).
|
|
98
|
+
def topo_order
|
|
99
|
+
index = @nodes.each_with_index.to_h { |n, i| [n.id, i] }
|
|
100
|
+
indegree = @nodes.to_h { |n| [n.id, 0] }
|
|
101
|
+
downstream = @nodes.to_h { |n| [n.id, []] }
|
|
102
|
+
@nodes.each do |n|
|
|
103
|
+
n.requires.each do |dep|
|
|
104
|
+
raise "node #{n.id} requires unknown node #{dep}" unless indegree.key?(dep)
|
|
105
|
+
|
|
106
|
+
indegree[n.id] += 1
|
|
107
|
+
downstream[dep] << n.id
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
ready = indegree.select { |_, d| d.zero? }.keys.sort_by { |id| index[id] }
|
|
111
|
+
by_id = @nodes.to_h { |n| [n.id, n] }
|
|
112
|
+
order = []
|
|
113
|
+
until ready.empty?
|
|
114
|
+
nid = ready.shift
|
|
115
|
+
order << by_id[nid]
|
|
116
|
+
downstream[nid].each do |nxt|
|
|
117
|
+
indegree[nxt] -= 1
|
|
118
|
+
ready << nxt if indegree[nxt].zero?
|
|
119
|
+
end
|
|
120
|
+
ready.sort_by! { |id| index[id] }
|
|
121
|
+
end
|
|
122
|
+
raise "DAG contains a cycle" if order.size != @nodes.size
|
|
123
|
+
|
|
124
|
+
order
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def to_h
|
|
128
|
+
{
|
|
129
|
+
"task_kind" => @task_kind, "fingerprint" => @fingerprint,
|
|
130
|
+
"path" => path, "nodes" => @nodes.map(&:to_h)
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.from_h(hash)
|
|
135
|
+
new(
|
|
136
|
+
task_kind: hash["task_kind"], fingerprint: hash["fingerprint"],
|
|
137
|
+
nodes: (hash["nodes"] || []).map { |n| Node.from_h(n) }
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module DagCache
|
|
8
|
+
# Task identity: structural fingerprints and path keys.
|
|
9
|
+
#
|
|
10
|
+
# Cache matching is deliberately *not* about argument values. Two calls
|
|
11
|
+
# are "the same task" when they hit the same entrypoint with inputs of
|
|
12
|
+
# the same *shape* (types and keys, never values). A cached solution is
|
|
13
|
+
# identified by its chain of tool names -- the path.
|
|
14
|
+
module Keys
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Recursive structural fingerprint of a value: types + hash keys only.
|
|
18
|
+
def structure(value)
|
|
19
|
+
case value
|
|
20
|
+
when NilClass then "none"
|
|
21
|
+
when TrueClass, FalseClass then "bool"
|
|
22
|
+
when Integer then "int"
|
|
23
|
+
when Float then "float"
|
|
24
|
+
when String then "str"
|
|
25
|
+
when Hash
|
|
26
|
+
{ "dict" => value.map { |k, v| [k.to_s, structure(v)] }.sort_by(&:first).to_h }
|
|
27
|
+
when Array
|
|
28
|
+
return { "list" => ["empty"] } if value.empty?
|
|
29
|
+
|
|
30
|
+
{ "list" => value.map { |v| JSON.generate(structure(v)) }.uniq.sort }
|
|
31
|
+
else
|
|
32
|
+
ivars = value.instance_variables.reject { |v| v.to_s.start_with?("@_") }
|
|
33
|
+
if ivars.any?
|
|
34
|
+
attrs = ivars.sort.to_h { |iv| [iv.to_s.delete_prefix("@"), structure(value.instance_variable_get(iv))] }
|
|
35
|
+
{ "obj" => value.class.name, "attrs" => attrs }
|
|
36
|
+
else
|
|
37
|
+
"other:#{value.class.name}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Stable hash of the *shape* of the call's arguments. +extra+ (from
|
|
43
|
+
# watch's key:) is mixed in by value to keep same-shaped but
|
|
44
|
+
# semantically different tasks apart.
|
|
45
|
+
def fingerprint(inputs, extra: nil)
|
|
46
|
+
shapes = inputs.map { |k, v| [k.to_s, structure(v)] }.sort_by(&:first).to_h
|
|
47
|
+
payload = JSON.generate({ "shapes" => shapes, "key" => extra })
|
|
48
|
+
Digest::SHA256.hexdigest(payload)[0, 16]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Stable hash of a tool chain, e.g. "search_kb > fetch_order".
|
|
52
|
+
def path_key(tool_names)
|
|
53
|
+
Digest::SHA256.hexdigest(tool_names.join(">"))[0, 16]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module DagCache
|
|
5
|
+
# Accumulates nodes for one agent run. Thread-local, so concurrent
|
|
6
|
+
# agents each get their own recording; the observed code runs unmodified.
|
|
7
|
+
class Recorder
|
|
8
|
+
attr_reader :task_kind, :fingerprint, :inputs, :nodes
|
|
9
|
+
|
|
10
|
+
def initialize(task_kind:, fingerprint:, inputs:)
|
|
11
|
+
@task_kind = task_kind
|
|
12
|
+
@fingerprint = fingerprint
|
|
13
|
+
@inputs = inputs
|
|
14
|
+
@nodes = []
|
|
15
|
+
@live_outputs = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.current
|
|
19
|
+
Thread.current[:dagcache_recorder]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record
|
|
23
|
+
Thread.current[:dagcache_recorder] = self
|
|
24
|
+
yield
|
|
25
|
+
ensure
|
|
26
|
+
Thread.current[:dagcache_recorder] = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_node(kind:, name:, purity:, args_live:, result_live:, duration_ms: 0.0)
|
|
30
|
+
nid = "n#{@nodes.size}"
|
|
31
|
+
recorded = Bindings.jsonable(result_live)
|
|
32
|
+
prior = @nodes.map { |n| [n.id, @live_outputs[n.id]] }
|
|
33
|
+
bindings = (args_live || {}).to_h do |k, v|
|
|
34
|
+
[k.to_s, Bindings.infer_binding(v, @inputs, prior)]
|
|
35
|
+
end
|
|
36
|
+
@nodes << Node.new(
|
|
37
|
+
id: nid, kind: kind, name: name, purity: purity,
|
|
38
|
+
args: bindings, recorded_output: recorded,
|
|
39
|
+
output_shape: Keys.structure(recorded), duration_ms: duration_ms
|
|
40
|
+
)
|
|
41
|
+
@live_outputs[nid] = result_live
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Compute Luigi-style +requires+ edges and return the DAG. Data deps
|
|
45
|
+
# come from node-output bindings; effectful and LLM nodes also depend
|
|
46
|
+
# on the previous node, preserving side-effect order.
|
|
47
|
+
def finalize
|
|
48
|
+
@nodes.each_with_index do |node, i|
|
|
49
|
+
deps = node.args.values.select { |b| b.source == "node" }.map { |b| b.path.first }.uniq
|
|
50
|
+
deps << @nodes[i - 1].id if i.positive? && node.purity != "pure"
|
|
51
|
+
node.requires = deps.sort_by { |d| d[1..].to_i }
|
|
52
|
+
end
|
|
53
|
+
DAG.new(task_kind: @task_kind, fingerprint: @fingerprint, nodes: @nodes)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module DagCache
|
|
7
|
+
# The Luigi-style worker: execute a cached DAG against new inputs.
|
|
8
|
+
#
|
|
9
|
+
# :verified (default) -- re-execute every tool with freshly resolved
|
|
10
|
+
# arguments (real side effects, fresh data) and re-run output LLM calls.
|
|
11
|
+
# Planning LLM calls are never re-run: the DAG *is* the plan. Any drift
|
|
12
|
+
# raises Divergence and the caller falls back to the live agent.
|
|
13
|
+
#
|
|
14
|
+
# :frozen -- VCR mode. Nothing executes; recorded outputs are returned.
|
|
15
|
+
module Replay
|
|
16
|
+
class Divergence < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Substitute fresh upstream outputs into recorded literal arguments
|
|
19
|
+
# (prompt patching): whole values, JSON forms, and changed leaves.
|
|
20
|
+
module Patching
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def replacement_pairs(recorded, fresh)
|
|
24
|
+
pairs = []
|
|
25
|
+
pairs << [recorded, fresh] if recorded.is_a?(String) && fresh.is_a?(String)
|
|
26
|
+
begin
|
|
27
|
+
pairs << [JSON.generate(recorded), JSON.generate(fresh)]
|
|
28
|
+
rescue JSON::GeneratorError
|
|
29
|
+
# not JSON-able; skip the JSON form
|
|
30
|
+
end
|
|
31
|
+
pairs << [recorded.to_s, fresh.to_s]
|
|
32
|
+
leaf_pairs(recorded, fresh, pairs)
|
|
33
|
+
# Bounded (non-substring) replacement makes short values safe.
|
|
34
|
+
pairs.uniq.select { |old, new| old.length >= 2 && old != new }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Pair up leaves at matching paths that changed between runs --
|
|
38
|
+
# strings, and numbers like prices/temps/quantities.
|
|
39
|
+
def leaf_pairs(recorded, fresh, out)
|
|
40
|
+
case recorded
|
|
41
|
+
when Hash
|
|
42
|
+
return unless fresh.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
recorded.each { |k, v| leaf_pairs(v, fresh[k], out) if fresh.key?(k) }
|
|
45
|
+
when Array
|
|
46
|
+
return unless fresh.is_a?(Array)
|
|
47
|
+
|
|
48
|
+
recorded.zip(fresh) { |a, b| leaf_pairs(a, b, out) }
|
|
49
|
+
when String
|
|
50
|
+
out << [recorded, fresh] if fresh.is_a?(String) && recorded != fresh
|
|
51
|
+
when Integer, Float
|
|
52
|
+
out << [recorded.to_s, fresh.to_s] if fresh.is_a?(recorded.class) && recorded != fresh
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def patch(value, dag, outputs)
|
|
57
|
+
case value
|
|
58
|
+
when String
|
|
59
|
+
dag.nodes.reduce(value) do |text, node|
|
|
60
|
+
next text unless outputs.key?(node.id) && !node.recorded_output.nil?
|
|
61
|
+
|
|
62
|
+
replacement_pairs(node.recorded_output, outputs[node.id]).reduce(text) do |t, (old, new)|
|
|
63
|
+
t.gsub(/(?<!\w)#{Regexp.escape(old)}(?!\w)/, new)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
when Array then value.map { |v| patch(v, dag, outputs) }
|
|
67
|
+
when Hash then value.to_h { |k, v| [k, patch(v, dag, outputs)] }
|
|
68
|
+
else value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class Executor
|
|
74
|
+
def initialize(mode = :verified)
|
|
75
|
+
mode = mode.to_sym
|
|
76
|
+
raise ArgumentError, "mode must be :verified or :frozen" unless %i[verified frozen].include?(mode)
|
|
77
|
+
|
|
78
|
+
@mode = mode
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def run(dag, inputs)
|
|
82
|
+
outputs = {}
|
|
83
|
+
dag.topo_order.each do |node|
|
|
84
|
+
if node.kind == "llm_plan" || @mode == :frozen
|
|
85
|
+
# The plan is what's cached; frozen mode caches outputs too.
|
|
86
|
+
outputs[node.id] = node.recorded_output
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
callable, style = callable_for(node)
|
|
90
|
+
args = {}
|
|
91
|
+
node.args.each do |arg_name, binding|
|
|
92
|
+
args[arg_name.to_sym] =
|
|
93
|
+
begin
|
|
94
|
+
Bindings.resolve(binding, inputs, outputs)
|
|
95
|
+
rescue Bindings::BindingError => e
|
|
96
|
+
raise Divergence, "#{node.name}.#{arg_name}: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
args = Patching.patch(args, dag, outputs)
|
|
100
|
+
fresh =
|
|
101
|
+
begin
|
|
102
|
+
style == :kwargs ? callable.call(**args) : callable.call(*args.values)
|
|
103
|
+
rescue Divergence
|
|
104
|
+
raise
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
raise Divergence, "#{node.name} raised #{e.class}: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
unless Keys.structure(Bindings.jsonable(fresh)) == node.output_shape
|
|
109
|
+
raise Divergence, "#{node.name} output shape drifted"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
outputs[node.id] = fresh
|
|
113
|
+
end
|
|
114
|
+
outputs[dag.terminal.id]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def callable_for(node)
|
|
120
|
+
entry =
|
|
121
|
+
case node.kind
|
|
122
|
+
when "tool" then DagCache.tools[node.name]
|
|
123
|
+
when "llm_output" then DagCache.llms[node.name]
|
|
124
|
+
end
|
|
125
|
+
raise Divergence, "no live callable registered for #{node.kind}:#{node.name}" if entry.nil?
|
|
126
|
+
|
|
127
|
+
[entry[:callable], entry[:style]]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module DagCache
|
|
9
|
+
# Persistence: one YAML cassette per DAG in the store directory --
|
|
10
|
+
# VCR-gem style, diffable in code review. Rows are keyed by
|
|
11
|
+
# (task_kind, fingerprint, path_key): recording the same chain for the
|
|
12
|
+
# same task shape again just bumps +recordings+ -- repetition is how a
|
|
13
|
+
# path earns "canonical". Competing paths coexist and rank by stats.
|
|
14
|
+
class Store
|
|
15
|
+
def initialize(dir)
|
|
16
|
+
@dir = dir
|
|
17
|
+
FileUtils.mkdir_p(dir)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def save_dag(dag, ttl_seconds: nil)
|
|
21
|
+
existing = load_all.find do |d|
|
|
22
|
+
d.task_kind == dag.task_kind && d.fingerprint == dag.fingerprint && d.path_key == dag.path_key
|
|
23
|
+
end
|
|
24
|
+
if existing
|
|
25
|
+
existing.recordings += 1
|
|
26
|
+
existing.nodes = dag.nodes
|
|
27
|
+
persist(existing)
|
|
28
|
+
existing.id
|
|
29
|
+
else
|
|
30
|
+
dag.id = next_id
|
|
31
|
+
dag.status = "staging"
|
|
32
|
+
dag.created_at = Time.now.utc.iso8601
|
|
33
|
+
dag.ttl_seconds = ttl_seconds
|
|
34
|
+
persist(dag)
|
|
35
|
+
dag.id
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def lookup(task_kind, fingerprint, auto_replay: true)
|
|
40
|
+
candidates = load_all.select do |d|
|
|
41
|
+
d.task_kind == task_kind && d.fingerprint == fingerprint &&
|
|
42
|
+
(d.status == "approved" || (auto_replay && d.status == "staging")) &&
|
|
43
|
+
!expired?(d)
|
|
44
|
+
end
|
|
45
|
+
candidates.min_by { |d| [d.status == "approved" ? 0 : 1, -(d.hits + d.recordings), d.id] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def get(id)
|
|
49
|
+
load_all.find { |d| d.id == id }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def record_hit(id)
|
|
53
|
+
update(id) { |d| d.hits += 1 }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def record_fallback(id, demote_threshold: 3)
|
|
57
|
+
update(id) do |d|
|
|
58
|
+
d.fallbacks += 1
|
|
59
|
+
d.status = "dead" if d.status == "staging" && demote_threshold.positive? && d.fallbacks >= demote_threshold
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def approve(id) = update(id) { |d| d.status = "approved" }
|
|
64
|
+
def demote(id) = update(id) { |d| d.status = "staging" }
|
|
65
|
+
|
|
66
|
+
def prune(status: nil)
|
|
67
|
+
dropped = load_all.select { |d| status.nil? || d.status == status }
|
|
68
|
+
dropped.each { |d| FileUtils.rm_f(file_for(d.id)) }
|
|
69
|
+
dropped.size
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def load_all
|
|
73
|
+
Dir.glob(File.join(@dir, "*.yml")).sort.map do |file|
|
|
74
|
+
from_storage(YAML.safe_load_file(file))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def update(id)
|
|
81
|
+
dag = get(id)
|
|
82
|
+
return false unless dag
|
|
83
|
+
|
|
84
|
+
yield dag
|
|
85
|
+
persist(dag)
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def persist(dag)
|
|
90
|
+
File.write(file_for(dag.id), YAML.dump(to_storage(dag)))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def file_for(id)
|
|
94
|
+
File.join(@dir, format("%<id>04d.yml", id: id))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def next_id
|
|
98
|
+
(load_all.map(&:id).max || 0) + 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def expired?(dag)
|
|
102
|
+
return false if dag.ttl_seconds.nil? || dag.created_at.nil?
|
|
103
|
+
|
|
104
|
+
Time.now.utc > Time.iso8601(dag.created_at) + dag.ttl_seconds
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def to_storage(dag)
|
|
108
|
+
{
|
|
109
|
+
"id" => dag.id, "task_kind" => dag.task_kind, "fingerprint" => dag.fingerprint,
|
|
110
|
+
"status" => dag.status, "recordings" => dag.recordings, "hits" => dag.hits,
|
|
111
|
+
"fallbacks" => dag.fallbacks, "created_at" => dag.created_at,
|
|
112
|
+
"ttl_seconds" => dag.ttl_seconds, "dag" => dag.to_h
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def from_storage(hash)
|
|
117
|
+
dag = DAG.from_h(hash["dag"])
|
|
118
|
+
dag.id = hash["id"]
|
|
119
|
+
dag.status = hash["status"]
|
|
120
|
+
dag.recordings = hash["recordings"]
|
|
121
|
+
dag.hits = hash["hits"]
|
|
122
|
+
dag.fallbacks = hash["fallbacks"]
|
|
123
|
+
dag.created_at = hash["created_at"]
|
|
124
|
+
dag.ttl_seconds = hash["ttl_seconds"]
|
|
125
|
+
dag
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module DagCache
|
|
5
|
+
# Prepended onto each RubyLLM::Tool subclass: while a recording is
|
|
6
|
+
# active, every tool execution becomes a node in the DAG and the tool
|
|
7
|
+
# instance is registered so verified replay can re-execute it later.
|
|
8
|
+
#
|
|
9
|
+
# We prepend per-subclass (via an +inherited+ hook plus a sweep of
|
|
10
|
+
# already-defined subclasses) because a subclass's own #execute would
|
|
11
|
+
# shadow a module prepended to RubyLLM::Tool itself.
|
|
12
|
+
#
|
|
13
|
+
# Mark a tool as having side effects by defining +dagcache_effectful?+
|
|
14
|
+
# on the class:
|
|
15
|
+
#
|
|
16
|
+
# class Refund < RubyLLM::Tool
|
|
17
|
+
# def self.dagcache_effectful? = true
|
|
18
|
+
# def execute(order_id:) = Payment.refund(order_id)
|
|
19
|
+
# end
|
|
20
|
+
module ToolPatch
|
|
21
|
+
def execute(**args)
|
|
22
|
+
recorder = DagCache.recorder
|
|
23
|
+
return super if recorder.nil?
|
|
24
|
+
|
|
25
|
+
name = DagCache.tool_name_for(self)
|
|
26
|
+
DagCache.register_tool_instance(name, self)
|
|
27
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
28
|
+
result = super
|
|
29
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
|
|
30
|
+
recorder.add_node(
|
|
31
|
+
kind: "tool", name: name, purity: DagCache.purity_for(self),
|
|
32
|
+
args_live: args, result_live: result, duration_ms: duration_ms
|
|
33
|
+
)
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Intercepts subclasses defined after dagcache is loaded.
|
|
38
|
+
module InheritedHook
|
|
39
|
+
def inherited(subclass)
|
|
40
|
+
super
|
|
41
|
+
ToolPatch.apply(subclass)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.apply(tool_class)
|
|
46
|
+
tool_class.prepend(self) unless tool_class.ancestors.include?(self)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.install!
|
|
50
|
+
RubyLLM::Tool.singleton_class.prepend(InheritedHook)
|
|
51
|
+
RubyLLM::Tool.subclasses.each { |subclass| apply(subclass) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
RubyLLM::DagCache::ToolPatch.install! if defined?(RubyLLM::Tool)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dagcache/version"
|
|
4
|
+
require_relative "dagcache/configuration"
|
|
5
|
+
require_relative "dagcache/keys"
|
|
6
|
+
require_relative "dagcache/graph"
|
|
7
|
+
require_relative "dagcache/bindings"
|
|
8
|
+
require_relative "dagcache/recorder"
|
|
9
|
+
require_relative "dagcache/store"
|
|
10
|
+
require_relative "dagcache/replay"
|
|
11
|
+
require_relative "dagcache/agent"
|
|
12
|
+
|
|
13
|
+
module RubyLLM
|
|
14
|
+
# DagCache: VCR cassettes for agent trajectories.
|
|
15
|
+
#
|
|
16
|
+
# Record agent runs as DAGs of tool/LLM calls, replay the canonical path
|
|
17
|
+
# on repeat tasks, and only pay for the LLM when the world diverges.
|
|
18
|
+
#
|
|
19
|
+
# search = RubyLLM::DagCache.tool("search_kb", pure: true) { |query:| KB.search(query) }
|
|
20
|
+
# draft = RubyLLM::DagCache.llm("draft") { |prompt| RubyLLM.chat.ask(prompt).content }
|
|
21
|
+
# agent = RubyLLM::DagCache.watch(MyAgent.new, key: ->(msg) { classify(msg) })
|
|
22
|
+
# agent.ask("where is my order O-123") # 1st: live + record; 2nd: replay
|
|
23
|
+
module DagCache
|
|
24
|
+
class << self
|
|
25
|
+
def configuration
|
|
26
|
+
@configuration ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def configure
|
|
30
|
+
yield configuration
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset_configuration!
|
|
34
|
+
@configuration = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# -- registries ------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def tools
|
|
40
|
+
@tools ||= {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def llms
|
|
44
|
+
@llms ||= {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register_tool_instance(name, instance)
|
|
48
|
+
tools[name] = { callable: ->(**a) { instance.execute(**a) }, style: :kwargs }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tool_name_for(tool_instance)
|
|
52
|
+
klass = tool_instance.class
|
|
53
|
+
return klass.dagcache_name if klass.respond_to?(:dagcache_name)
|
|
54
|
+
|
|
55
|
+
klass.name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def purity_for(tool_instance)
|
|
59
|
+
klass = tool_instance.class
|
|
60
|
+
klass.respond_to?(:dagcache_effectful?) && klass.dagcache_effectful? ? "effectful" : "pure"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# -- DSL -------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
# Define a cacheable tool (keyword arguments, RubyLLM-style):
|
|
66
|
+
# search = DagCache.tool("search_kb", pure: true) { |query:| ... }
|
|
67
|
+
# search.call(query: "refunds")
|
|
68
|
+
def tool(name, pure: true, &block)
|
|
69
|
+
tools[name] = { callable: block, style: :kwargs }
|
|
70
|
+
purity = pure ? "pure" : "effectful"
|
|
71
|
+
lambda do |**args|
|
|
72
|
+
recorder = Recorder.current
|
|
73
|
+
return block.call(**args) if recorder.nil?
|
|
74
|
+
|
|
75
|
+
result = block.call(**args)
|
|
76
|
+
recorder.add_node(kind: "tool", name: name, purity: purity, args_live: args, result_live: result)
|
|
77
|
+
result
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Define an LLM call (positional arguments):
|
|
82
|
+
# draft = DagCache.llm("draft") { |prompt| ... }
|
|
83
|
+
# draft.call("write a reply about ...")
|
|
84
|
+
# planning: true => decision call, never re-executed at replay.
|
|
85
|
+
def llm(name, planning: false, &block)
|
|
86
|
+
llms[name] = { callable: block, style: :positional }
|
|
87
|
+
kind = planning ? "llm_plan" : "llm_output"
|
|
88
|
+
lambda do |*args|
|
|
89
|
+
recorder = Recorder.current
|
|
90
|
+
return block.call(*args) if recorder.nil?
|
|
91
|
+
|
|
92
|
+
args_live = args.each_with_index.to_h { |v, i| ["arg#{i}", v] }
|
|
93
|
+
result = block.call(*args)
|
|
94
|
+
recorder.add_node(kind: kind, name: name, purity: "llm", args_live: args_live, result_live: result)
|
|
95
|
+
result
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# -- engine ----------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def recorder
|
|
102
|
+
Recorder.current
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Wrap any object responding to #ask (RubyLLM::Agent, Chat, or your
|
|
106
|
+
# own class) so calls are recorded/replayed. See DagCache::Agent.
|
|
107
|
+
def watch(agent, kind: nil, key: nil)
|
|
108
|
+
Agent.watch(agent, kind: kind, key: key)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The @agent equivalent: lookup -> replay -> (divergence? fall back)
|
|
112
|
+
# -> run live while recording -> store candidate path.
|
|
113
|
+
def run_cached(task_kind:, inputs:, key: nil)
|
|
114
|
+
return yield unless configuration.enabled
|
|
115
|
+
|
|
116
|
+
fingerprint = Keys.fingerprint(inputs, extra: key)
|
|
117
|
+
store = Store.new(configuration.store_path)
|
|
118
|
+
|
|
119
|
+
unless configuration.force_record
|
|
120
|
+
dag = store.lookup(task_kind, fingerprint, auto_replay: configuration.auto_replay)
|
|
121
|
+
if dag
|
|
122
|
+
begin
|
|
123
|
+
result = Replay::Executor.new(configuration.replay_mode).run(dag, inputs)
|
|
124
|
+
rescue Replay::Divergence
|
|
125
|
+
store.record_fallback(dag.id, demote_threshold: configuration.fallback_demote_threshold)
|
|
126
|
+
else
|
|
127
|
+
store.record_hit(dag.id)
|
|
128
|
+
return result
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
recorder = Recorder.new(task_kind: task_kind, fingerprint: fingerprint, inputs: inputs)
|
|
134
|
+
result = recorder.record { yield }
|
|
135
|
+
store.save_dag(recorder.finalize, ttl_seconds: configuration.default_ttl_seconds) unless recorder.nodes.empty?
|
|
136
|
+
result
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
require_relative "dagcache/tool_patch"
|
metadata
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_llm-dagcache
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- dagcache contributors
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Record RubyLLM agent runs as DAGs of tool/LLM calls, replay the canonical
|
|
13
|
+
path on repeat tasks, and only call the LLM for net-new paths. Same mental model
|
|
14
|
+
as the Python dagcache library.
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/ruby_llm/dagcache.rb
|
|
22
|
+
- lib/ruby_llm/dagcache/agent.rb
|
|
23
|
+
- lib/ruby_llm/dagcache/bindings.rb
|
|
24
|
+
- lib/ruby_llm/dagcache/configuration.rb
|
|
25
|
+
- lib/ruby_llm/dagcache/graph.rb
|
|
26
|
+
- lib/ruby_llm/dagcache/keys.rb
|
|
27
|
+
- lib/ruby_llm/dagcache/recorder.rb
|
|
28
|
+
- lib/ruby_llm/dagcache/replay.rb
|
|
29
|
+
- lib/ruby_llm/dagcache/store.rb
|
|
30
|
+
- lib/ruby_llm/dagcache/tool_patch.rb
|
|
31
|
+
- lib/ruby_llm/dagcache/version.rb
|
|
32
|
+
homepage: https://github.com/itstheraj/ruby_llm-dagcache
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://github.com/itstheraj/ruby_llm-dagcache
|
|
37
|
+
bug_tracker_uri: https://github.com/itstheraj/ruby_llm-dagcache/issues
|
|
38
|
+
rubygems_mfa_required: 'true'
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 4.0.20
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: VCR cassettes for RubyLLM agent trajectories
|
|
56
|
+
test_files: []
|