graph-agent 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/.github/workflows/ci.yml +50 -0
- data/.github/workflows/release.yml +49 -0
- data/.gitignore +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +126 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +128 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +94 -0
- data/LICENSE +21 -0
- data/Makefile +114 -0
- data/README.md +464 -0
- data/Rakefile +15 -0
- data/docs/README.md +55 -0
- data/docs/api_reference.md +832 -0
- data/docs/concepts.md +216 -0
- data/docs/edges.md +265 -0
- data/docs/error_handling.md +241 -0
- data/docs/human_in_the_loop.md +231 -0
- data/docs/persistence.md +276 -0
- data/docs/quickstart.md +154 -0
- data/docs/send_and_command.md +218 -0
- data/docs/state.md +181 -0
- data/docs/streaming.md +172 -0
- data/graph-agent.gemspec +48 -0
- data/lib/graph_agent/channels/base_channel.rb +52 -0
- data/lib/graph_agent/channels/binary_operator_aggregate.rb +56 -0
- data/lib/graph_agent/channels/ephemeral_value.rb +59 -0
- data/lib/graph_agent/channels/last_value.rb +49 -0
- data/lib/graph_agent/channels/topic.rb +58 -0
- data/lib/graph_agent/checkpoint/base_saver.rb +38 -0
- data/lib/graph_agent/checkpoint/in_memory_saver.rb +145 -0
- data/lib/graph_agent/constants.rb +9 -0
- data/lib/graph_agent/errors.rb +41 -0
- data/lib/graph_agent/graph/compiled_state_graph.rb +362 -0
- data/lib/graph_agent/graph/conditional_edge.rb +57 -0
- data/lib/graph_agent/graph/edge.rb +23 -0
- data/lib/graph_agent/graph/mermaid_visualizer.rb +154 -0
- data/lib/graph_agent/graph/message_graph.rb +18 -0
- data/lib/graph_agent/graph/node.rb +61 -0
- data/lib/graph_agent/graph/state_graph.rb +197 -0
- data/lib/graph_agent/reducers.rb +34 -0
- data/lib/graph_agent/state/schema.rb +54 -0
- data/lib/graph_agent/types/cache_policy.rb +12 -0
- data/lib/graph_agent/types/command.rb +26 -0
- data/lib/graph_agent/types/interrupt.rb +28 -0
- data/lib/graph_agent/types/retry_policy.rb +42 -0
- data/lib/graph_agent/types/send.rb +26 -0
- data/lib/graph_agent/types/state_snapshot.rb +28 -0
- data/lib/graph_agent/version.rb +5 -0
- data/lib/graph_agent.rb +29 -0
- metadata +158 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphAgent
|
|
4
|
+
module Channels
|
|
5
|
+
class BinaryOperatorAggregate < BaseChannel
|
|
6
|
+
attr_reader :operator
|
|
7
|
+
|
|
8
|
+
def initialize(operator:, key: "", default: MISSING)
|
|
9
|
+
super(key: key)
|
|
10
|
+
@operator = operator
|
|
11
|
+
@value = default
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get
|
|
15
|
+
raise EmptyChannelError.new("Channel '#{key}' is empty") if @value.equal?(MISSING)
|
|
16
|
+
|
|
17
|
+
@value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update(values)
|
|
21
|
+
return false if values.empty?
|
|
22
|
+
|
|
23
|
+
if @value.equal?(MISSING)
|
|
24
|
+
@value = values.first
|
|
25
|
+
values = values[1..]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
values.each do |value|
|
|
29
|
+
@value = @operator.call(@value, value)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def available?
|
|
36
|
+
!@value.equal?(MISSING)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def checkpoint
|
|
40
|
+
@value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def from_checkpoint(checkpoint)
|
|
44
|
+
ch = self.class.new(operator: @operator, key: key)
|
|
45
|
+
ch.instance_variable_set(:@value, checkpoint.equal?(MISSING) ? MISSING : checkpoint)
|
|
46
|
+
ch
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def copy
|
|
50
|
+
ch = self.class.new(operator: @operator, key: key)
|
|
51
|
+
ch.instance_variable_set(:@value, @value)
|
|
52
|
+
ch
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphAgent
|
|
4
|
+
module Channels
|
|
5
|
+
class EphemeralValue < BaseChannel
|
|
6
|
+
def initialize(key: "", guard: true)
|
|
7
|
+
super(key: key)
|
|
8
|
+
@guard = guard
|
|
9
|
+
@value = MISSING
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def get
|
|
13
|
+
raise EmptyChannelError.new("Channel '#{key}' is empty") if @value.equal?(MISSING)
|
|
14
|
+
|
|
15
|
+
@value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def update(values)
|
|
19
|
+
if values.empty?
|
|
20
|
+
if @value.equal?(MISSING)
|
|
21
|
+
return false
|
|
22
|
+
else
|
|
23
|
+
@value = MISSING
|
|
24
|
+
return true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if values.length != 1 && @guard
|
|
29
|
+
raise InvalidUpdateError.new(
|
|
30
|
+
"At key '#{key}': EphemeralValue(guard=true) can receive only one value per step."
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@value = values.last
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def available?
|
|
39
|
+
!@value.equal?(MISSING)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def checkpoint
|
|
43
|
+
@value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def from_checkpoint(checkpoint)
|
|
47
|
+
ch = self.class.new(key: key, guard: @guard)
|
|
48
|
+
ch.instance_variable_set(:@value, checkpoint.equal?(MISSING) ? MISSING : checkpoint)
|
|
49
|
+
ch
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def copy
|
|
53
|
+
ch = self.class.new(key: key, guard: @guard)
|
|
54
|
+
ch.instance_variable_set(:@value, @value)
|
|
55
|
+
ch
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphAgent
|
|
4
|
+
module Channels
|
|
5
|
+
class LastValue < BaseChannel
|
|
6
|
+
def initialize(key: "", default: MISSING)
|
|
7
|
+
super(key: key)
|
|
8
|
+
@value = default
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def get
|
|
12
|
+
raise EmptyChannelError.new("Channel '#{key}' is empty") if @value.equal?(MISSING)
|
|
13
|
+
|
|
14
|
+
@value
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update(values)
|
|
18
|
+
return false if values.empty?
|
|
19
|
+
|
|
20
|
+
if values.length != 1
|
|
21
|
+
raise InvalidUpdateError.new("At key '#{key}': Can receive only one value per step.")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@value = values.last
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def available?
|
|
29
|
+
!@value.equal?(MISSING)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def checkpoint
|
|
33
|
+
@value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def from_checkpoint(checkpoint)
|
|
37
|
+
ch = self.class.new(key: key)
|
|
38
|
+
ch.instance_variable_set(:@value, checkpoint.equal?(MISSING) ? MISSING : checkpoint)
|
|
39
|
+
ch
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def copy
|
|
43
|
+
ch = self.class.new(key: key)
|
|
44
|
+
ch.instance_variable_set(:@value, @value)
|
|
45
|
+
ch
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphAgent
|
|
4
|
+
module Channels
|
|
5
|
+
class Topic < BaseChannel
|
|
6
|
+
attr_reader :accumulate
|
|
7
|
+
|
|
8
|
+
def initialize(key: "", accumulate: false)
|
|
9
|
+
super(key: key)
|
|
10
|
+
@accumulate = accumulate
|
|
11
|
+
@values = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get
|
|
15
|
+
raise EmptyChannelError.new("Channel '#{key}' is empty") if @values.empty?
|
|
16
|
+
|
|
17
|
+
@values.dup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update(values)
|
|
21
|
+
updated = false
|
|
22
|
+
|
|
23
|
+
unless @accumulate
|
|
24
|
+
updated = !@values.empty?
|
|
25
|
+
@values = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
flat = values.flat_map { |v| v.is_a?(Array) ? v : [v] }
|
|
29
|
+
unless flat.empty?
|
|
30
|
+
updated = true
|
|
31
|
+
@values.concat(flat)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
updated
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def available?
|
|
38
|
+
!@values.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def checkpoint
|
|
42
|
+
@values.dup
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def from_checkpoint(checkpoint)
|
|
46
|
+
ch = self.class.new(key: key, accumulate: @accumulate)
|
|
47
|
+
ch.instance_variable_set(:@values, checkpoint.equal?(MISSING) ? [] : Array(checkpoint))
|
|
48
|
+
ch
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def copy
|
|
52
|
+
ch = self.class.new(key: key, accumulate: @accumulate)
|
|
53
|
+
ch.instance_variable_set(:@values, @values.dup)
|
|
54
|
+
ch
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphAgent
|
|
4
|
+
module Checkpoint
|
|
5
|
+
CheckpointTuple = Data.define(:config, :checkpoint, :metadata, :parent_config, :pending_writes) do
|
|
6
|
+
def initialize(config:, checkpoint:, metadata: nil, parent_config: nil, pending_writes: [])
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class BaseSaver
|
|
12
|
+
def get(config)
|
|
13
|
+
tuple = get_tuple(config)
|
|
14
|
+
tuple&.checkpoint
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def get_tuple(config)
|
|
18
|
+
raise NotImplementedError.new("#{self.class}#get_tuple must be implemented")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def list(config, filter: nil, before: nil, limit: nil)
|
|
22
|
+
raise NotImplementedError.new("#{self.class}#list must be implemented")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def put(config, checkpoint, metadata, new_versions)
|
|
26
|
+
raise NotImplementedError.new("#{self.class}#put must be implemented")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def put_writes(config, writes, task_id)
|
|
30
|
+
raise NotImplementedError.new("#{self.class}#put_writes must be implemented")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def delete_thread(thread_id)
|
|
34
|
+
raise NotImplementedError.new("#{self.class}#delete_thread must be implemented")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module GraphAgent
|
|
6
|
+
module Checkpoint
|
|
7
|
+
class InMemorySaver < BaseSaver
|
|
8
|
+
def initialize
|
|
9
|
+
super
|
|
10
|
+
@storage = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = {} } }
|
|
11
|
+
@writes = Hash.new { |h, k| h[k] = {} }
|
|
12
|
+
@order = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = [] } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_tuple(config)
|
|
16
|
+
thread_id = config.dig(:configurable, :thread_id)
|
|
17
|
+
checkpoint_ns = config.dig(:configurable, :checkpoint_ns) || ""
|
|
18
|
+
checkpoint_id = config.dig(:configurable, :checkpoint_id)
|
|
19
|
+
|
|
20
|
+
if checkpoint_id
|
|
21
|
+
saved = @storage[thread_id][checkpoint_ns][checkpoint_id]
|
|
22
|
+
return nil unless saved
|
|
23
|
+
|
|
24
|
+
build_tuple(thread_id, checkpoint_ns, checkpoint_id, saved, config)
|
|
25
|
+
else
|
|
26
|
+
checkpoints = @storage[thread_id][checkpoint_ns]
|
|
27
|
+
return nil if checkpoints.empty?
|
|
28
|
+
|
|
29
|
+
checkpoint_id = @order[thread_id][checkpoint_ns].last
|
|
30
|
+
return nil unless checkpoint_id
|
|
31
|
+
|
|
32
|
+
saved = checkpoints[checkpoint_id]
|
|
33
|
+
result_config = {
|
|
34
|
+
configurable: {
|
|
35
|
+
thread_id: thread_id,
|
|
36
|
+
checkpoint_ns: checkpoint_ns,
|
|
37
|
+
checkpoint_id: checkpoint_id
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
build_tuple(thread_id, checkpoint_ns, checkpoint_id, saved, result_config)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def list(config, filter: nil, before: nil, limit: nil)
|
|
45
|
+
thread_ids = config ? [config.dig(:configurable, :thread_id)] : @storage.keys
|
|
46
|
+
results = []
|
|
47
|
+
|
|
48
|
+
thread_ids.each do |thread_id|
|
|
49
|
+
@storage[thread_id].each do |checkpoint_ns, checkpoints|
|
|
50
|
+
_list_ns(thread_id, checkpoint_ns, checkpoints, results, filter: filter, before: before, limit: limit)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
results
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def put(config, checkpoint, metadata, new_versions)
|
|
58
|
+
thread_id = config.dig(:configurable, :thread_id)
|
|
59
|
+
checkpoint_ns = config.dig(:configurable, :checkpoint_ns) || ""
|
|
60
|
+
checkpoint_id = checkpoint[:id] || SecureRandom.uuid
|
|
61
|
+
|
|
62
|
+
@storage[thread_id][checkpoint_ns][checkpoint_id] = {
|
|
63
|
+
checkpoint: checkpoint,
|
|
64
|
+
metadata: metadata,
|
|
65
|
+
parent_checkpoint_id: config.dig(:configurable, :checkpoint_id)
|
|
66
|
+
}
|
|
67
|
+
@order[thread_id][checkpoint_ns] << checkpoint_id
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
configurable: {
|
|
71
|
+
thread_id: thread_id,
|
|
72
|
+
checkpoint_ns: checkpoint_ns,
|
|
73
|
+
checkpoint_id: checkpoint_id
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def put_writes(config, writes, task_id)
|
|
79
|
+
thread_id = config.dig(:configurable, :thread_id)
|
|
80
|
+
checkpoint_ns = config.dig(:configurable, :checkpoint_ns) || ""
|
|
81
|
+
checkpoint_id = config.dig(:configurable, :checkpoint_id)
|
|
82
|
+
key = [thread_id, checkpoint_ns, checkpoint_id]
|
|
83
|
+
|
|
84
|
+
writes.each_with_index do |(channel, value), idx|
|
|
85
|
+
@writes[key][[task_id, idx]] = { task_id: task_id, channel: channel, value: value }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def delete_thread(thread_id)
|
|
90
|
+
@storage.delete(thread_id)
|
|
91
|
+
@order.delete(thread_id)
|
|
92
|
+
@writes.delete_if { |key, _| key.first == thread_id }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def _list_ns(thread_id, checkpoint_ns, checkpoints, results, filter:, before:, limit:)
|
|
98
|
+
ordered_ids = @order[thread_id][checkpoint_ns].reverse
|
|
99
|
+
entries = ordered_ids.filter_map { |id| [id, checkpoints[id]] if checkpoints[id] }
|
|
100
|
+
|
|
101
|
+
entries.each do |checkpoint_id, saved|
|
|
102
|
+
next if before && _skip_before?(checkpoint_id, before)
|
|
103
|
+
next if filter && !_matches_filter?(saved[:metadata], filter)
|
|
104
|
+
break if limit && results.length >= limit
|
|
105
|
+
|
|
106
|
+
tuple_config = { configurable: { thread_id: thread_id, checkpoint_ns: checkpoint_ns,
|
|
107
|
+
checkpoint_id: checkpoint_id } }
|
|
108
|
+
results << build_tuple(thread_id, checkpoint_ns, checkpoint_id, saved, tuple_config)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def _skip_before?(checkpoint_id, before)
|
|
113
|
+
before_id = before.dig(:configurable, :checkpoint_id)
|
|
114
|
+
before_id && checkpoint_id >= before_id
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def _matches_filter?(metadata, filter)
|
|
118
|
+
filter.all? { |k, v| metadata[k] == v }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_tuple(thread_id, checkpoint_ns, checkpoint_id, saved, config)
|
|
122
|
+
pending = @writes[[thread_id, checkpoint_ns, checkpoint_id]].values
|
|
123
|
+
parent_id = saved[:parent_checkpoint_id]
|
|
124
|
+
|
|
125
|
+
parent_config = if parent_id
|
|
126
|
+
{
|
|
127
|
+
configurable: {
|
|
128
|
+
thread_id: thread_id,
|
|
129
|
+
checkpoint_ns: checkpoint_ns,
|
|
130
|
+
checkpoint_id: parent_id
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
CheckpointTuple.new(
|
|
136
|
+
config: config,
|
|
137
|
+
checkpoint: saved[:checkpoint],
|
|
138
|
+
metadata: saved[:metadata],
|
|
139
|
+
parent_config: parent_config,
|
|
140
|
+
pending_writes: pending.map { |w| [w[:task_id], w[:channel], w[:value]] }
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphAgent
|
|
4
|
+
class GraphError < StandardError; end
|
|
5
|
+
|
|
6
|
+
class GraphRecursionError < GraphError
|
|
7
|
+
def initialize(msg = nil)
|
|
8
|
+
super(msg || "Graph has exhausted the maximum number of steps. " \
|
|
9
|
+
"To increase the limit, set recursion_limit in config.")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class InvalidUpdateError < GraphError; end
|
|
14
|
+
|
|
15
|
+
class EmptyChannelError < GraphError; end
|
|
16
|
+
|
|
17
|
+
class InvalidGraphError < GraphError; end
|
|
18
|
+
|
|
19
|
+
class NodeExecutionError < GraphError
|
|
20
|
+
attr_reader :node_name, :original_error
|
|
21
|
+
|
|
22
|
+
def initialize(node_name, original_error)
|
|
23
|
+
@node_name = node_name
|
|
24
|
+
@original_error = original_error
|
|
25
|
+
super("Error in node '#{node_name}': #{original_error.message}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class GraphInterrupt < GraphError
|
|
30
|
+
attr_reader :interrupts
|
|
31
|
+
|
|
32
|
+
def initialize(interrupts = [])
|
|
33
|
+
@interrupts = interrupts
|
|
34
|
+
super("Graph interrupted with #{interrupts.length} interrupt(s)")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class EmptyInputError < GraphError; end
|
|
39
|
+
|
|
40
|
+
class TaskNotFound < GraphError; end
|
|
41
|
+
end
|