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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/release.yml +49 -0
  4. data/.gitignore +6 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +126 -0
  7. data/CHANGELOG.md +26 -0
  8. data/CLAUDE.md +128 -0
  9. data/Gemfile +11 -0
  10. data/Gemfile.lock +94 -0
  11. data/LICENSE +21 -0
  12. data/Makefile +114 -0
  13. data/README.md +464 -0
  14. data/Rakefile +15 -0
  15. data/docs/README.md +55 -0
  16. data/docs/api_reference.md +832 -0
  17. data/docs/concepts.md +216 -0
  18. data/docs/edges.md +265 -0
  19. data/docs/error_handling.md +241 -0
  20. data/docs/human_in_the_loop.md +231 -0
  21. data/docs/persistence.md +276 -0
  22. data/docs/quickstart.md +154 -0
  23. data/docs/send_and_command.md +218 -0
  24. data/docs/state.md +181 -0
  25. data/docs/streaming.md +172 -0
  26. data/graph-agent.gemspec +48 -0
  27. data/lib/graph_agent/channels/base_channel.rb +52 -0
  28. data/lib/graph_agent/channels/binary_operator_aggregate.rb +56 -0
  29. data/lib/graph_agent/channels/ephemeral_value.rb +59 -0
  30. data/lib/graph_agent/channels/last_value.rb +49 -0
  31. data/lib/graph_agent/channels/topic.rb +58 -0
  32. data/lib/graph_agent/checkpoint/base_saver.rb +38 -0
  33. data/lib/graph_agent/checkpoint/in_memory_saver.rb +145 -0
  34. data/lib/graph_agent/constants.rb +9 -0
  35. data/lib/graph_agent/errors.rb +41 -0
  36. data/lib/graph_agent/graph/compiled_state_graph.rb +362 -0
  37. data/lib/graph_agent/graph/conditional_edge.rb +57 -0
  38. data/lib/graph_agent/graph/edge.rb +23 -0
  39. data/lib/graph_agent/graph/mermaid_visualizer.rb +154 -0
  40. data/lib/graph_agent/graph/message_graph.rb +18 -0
  41. data/lib/graph_agent/graph/node.rb +61 -0
  42. data/lib/graph_agent/graph/state_graph.rb +197 -0
  43. data/lib/graph_agent/reducers.rb +34 -0
  44. data/lib/graph_agent/state/schema.rb +54 -0
  45. data/lib/graph_agent/types/cache_policy.rb +12 -0
  46. data/lib/graph_agent/types/command.rb +26 -0
  47. data/lib/graph_agent/types/interrupt.rb +28 -0
  48. data/lib/graph_agent/types/retry_policy.rb +42 -0
  49. data/lib/graph_agent/types/send.rb +26 -0
  50. data/lib/graph_agent/types/state_snapshot.rb +28 -0
  51. data/lib/graph_agent/version.rb +5 -0
  52. data/lib/graph_agent.rb +29 -0
  53. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphAgent
4
+ START = :__start__
5
+ END_NODE = :__end__
6
+
7
+ TAG_NOSTREAM = :nostream
8
+ TAG_HIDDEN = :"langsmith:hidden"
9
+ 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