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 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
+ [![Ruby >= 3.1](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
+ [![Built for RubyLLM](https://img.shields.io/badge/built%20for-RubyLLM-7048e8)](https://github.com/crmne/ruby_llm)
10
+ [![Python sibling: dagcache](https://img.shields.io/badge/python%20sibling-dagcache-3776AB?logo=python&logoColor=white)](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
+ ![Terminal demo: record one run, replay the next with zero LLM planning](docs/demo.gif)
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module DagCache
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -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: []