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,231 @@
|
|
|
1
|
+
# Human-in-the-Loop
|
|
2
|
+
|
|
3
|
+
GraphAgent supports pausing graph execution so a human can inspect the state,
|
|
4
|
+
approve actions, provide input, or modify state before resuming.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
The human-in-the-loop pattern works in three steps:
|
|
11
|
+
|
|
12
|
+
1. **Interrupt** — the graph pauses before or after a specific node.
|
|
13
|
+
2. **Inspect / Modify** — the human reads the state, optionally modifies it.
|
|
14
|
+
3. **Resume** — the graph continues from where it left off.
|
|
15
|
+
|
|
16
|
+
This requires a **checkpointer** to persist state across the pause.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## interrupt_before and interrupt_after
|
|
21
|
+
|
|
22
|
+
Specify which nodes should trigger interrupts at compile time:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
checkpointer = GraphAgent::Checkpoint::InMemorySaver.new
|
|
26
|
+
|
|
27
|
+
app = graph.compile(
|
|
28
|
+
checkpointer: checkpointer,
|
|
29
|
+
interrupt_before: ["human_review"], # pause BEFORE this node runs
|
|
30
|
+
interrupt_after: ["draft"] # pause AFTER this node runs
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `interrupt_before` — pauses **before** the node executes. The node has not
|
|
35
|
+
yet seen or modified state.
|
|
36
|
+
- `interrupt_after` — pauses **after** the node executes and its updates are
|
|
37
|
+
applied.
|
|
38
|
+
|
|
39
|
+
Both accept an array of node name strings or symbols.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Catching GraphInterrupt
|
|
44
|
+
|
|
45
|
+
When an interrupt fires, the graph raises `GraphAgent::GraphInterrupt`:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
config = { configurable: { thread_id: "review-1" } }
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
result = app.invoke(input, config: config)
|
|
52
|
+
puts "Completed: #{result}"
|
|
53
|
+
rescue GraphAgent::GraphInterrupt => e
|
|
54
|
+
puts "Paused with #{e.interrupts.length} interrupt(s)"
|
|
55
|
+
e.interrupts.each do |interrupt|
|
|
56
|
+
puts " #{interrupt.value}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Each interrupt in the `interrupts` array is a `GraphAgent::Interrupt` object:
|
|
62
|
+
|
|
63
|
+
| Attribute | Type | Description |
|
|
64
|
+
|-----------|------|-------------|
|
|
65
|
+
| `value` | Object | Description of the interrupt (typically a String) |
|
|
66
|
+
| `id` | String | Unique identifier for this interrupt |
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Inspecting State with get_state
|
|
71
|
+
|
|
72
|
+
After an interrupt, inspect the saved state:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
snapshot = app.get_state(config)
|
|
76
|
+
|
|
77
|
+
puts "Current state: #{snapshot.values}"
|
|
78
|
+
puts "Next nodes: #{snapshot.next_nodes}"
|
|
79
|
+
puts "Step: #{snapshot.metadata[:step]}"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This lets the human review what the graph has computed so far and decide
|
|
83
|
+
whether to approve, modify, or reject.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Modifying State with update_state
|
|
88
|
+
|
|
89
|
+
The human can modify state before resuming:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
app.update_state(config, {
|
|
93
|
+
approved: true,
|
|
94
|
+
reviewer_notes: "Looks good, proceed"
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Updates respect reducers — if a field has a reducer, the update is merged
|
|
99
|
+
through it.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Resuming Execution
|
|
104
|
+
|
|
105
|
+
Resume by calling `invoke` with `nil` input and the same config:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
result = app.invoke(nil, config: config)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The graph picks up from the last checkpoint and continues execution.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Wildcard Interrupts
|
|
116
|
+
|
|
117
|
+
Use `"*"` to interrupt before or after **every** node:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
app = graph.compile(
|
|
121
|
+
checkpointer: checkpointer,
|
|
122
|
+
interrupt_before: ["*"]
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
This is useful for step-by-step debugging or approval workflows where every
|
|
127
|
+
action needs human review.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Full Example
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
require "graph_agent"
|
|
135
|
+
|
|
136
|
+
schema = GraphAgent::State::Schema.new do
|
|
137
|
+
field :messages, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
|
|
138
|
+
field :draft, type: String
|
|
139
|
+
field :approved, type: :boolean, default: false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
graph = GraphAgent::Graph::StateGraph.new(schema)
|
|
143
|
+
|
|
144
|
+
graph.add_node("generate_draft") do |state|
|
|
145
|
+
topic = state[:messages].last[:content]
|
|
146
|
+
{ draft: "Draft response about: #{topic}" }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
graph.add_node("human_review") do |state|
|
|
150
|
+
if state[:approved]
|
|
151
|
+
{ messages: [{ role: "ai", content: state[:draft] }] }
|
|
152
|
+
else
|
|
153
|
+
{ messages: [{ role: "ai", content: "Draft was rejected." }] }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
graph.add_edge(GraphAgent::START, "generate_draft")
|
|
158
|
+
graph.add_edge("generate_draft", "human_review")
|
|
159
|
+
graph.add_edge("human_review", GraphAgent::END_NODE)
|
|
160
|
+
|
|
161
|
+
checkpointer = GraphAgent::Checkpoint::InMemorySaver.new
|
|
162
|
+
app = graph.compile(
|
|
163
|
+
checkpointer: checkpointer,
|
|
164
|
+
interrupt_before: ["human_review"]
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
config = { configurable: { thread_id: "review-session-1" } }
|
|
168
|
+
|
|
169
|
+
# Step 1: Start execution — will pause before human_review
|
|
170
|
+
begin
|
|
171
|
+
app.invoke(
|
|
172
|
+
{ messages: [{ role: "user", content: "Write about Ruby" }] },
|
|
173
|
+
config: config
|
|
174
|
+
)
|
|
175
|
+
rescue GraphAgent::GraphInterrupt => e
|
|
176
|
+
puts "Interrupted: #{e.interrupts.first.value}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Step 2: Inspect the draft
|
|
180
|
+
snapshot = app.get_state(config)
|
|
181
|
+
puts "Draft: #{snapshot.values[:draft]}"
|
|
182
|
+
|
|
183
|
+
# Step 3: Approve and resume
|
|
184
|
+
app.update_state(config, { approved: true })
|
|
185
|
+
result = app.invoke(nil, config: config)
|
|
186
|
+
puts "Final: #{result[:messages].last[:content]}"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Patterns
|
|
192
|
+
|
|
193
|
+
### Approval Gate
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
graph.add_node("propose_action") do |state|
|
|
197
|
+
{ proposed_action: "Delete 50 records" }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
graph.add_node("execute_action") do |state|
|
|
201
|
+
if state[:approved]
|
|
202
|
+
{ result: "Deleted 50 records" }
|
|
203
|
+
else
|
|
204
|
+
{ result: "Action cancelled" }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
graph.add_edge(GraphAgent::START, "propose_action")
|
|
209
|
+
graph.add_edge("propose_action", "execute_action")
|
|
210
|
+
graph.add_edge("execute_action", GraphAgent::END_NODE)
|
|
211
|
+
|
|
212
|
+
app = graph.compile(
|
|
213
|
+
checkpointer: checkpointer,
|
|
214
|
+
interrupt_before: ["execute_action"]
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Human Input Collection
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
graph.add_node("ask_question") do |state|
|
|
222
|
+
{ question: "What is your preferred language?" }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
graph.add_node("process_answer") do |state|
|
|
226
|
+
{ result: "You chose: #{state[:human_input]}" }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# After interrupt, the human provides input via update_state:
|
|
230
|
+
# app.update_state(config, { human_input: "Ruby" })
|
|
231
|
+
```
|
data/docs/persistence.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Persistence & Checkpointing
|
|
2
|
+
|
|
3
|
+
Persistence enables multi-turn conversations, fault recovery, and
|
|
4
|
+
human-in-the-loop workflows by saving graph state between invocations.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Why Persistence Matters
|
|
9
|
+
|
|
10
|
+
Without persistence, every `invoke` starts from scratch. With a checkpointer:
|
|
11
|
+
|
|
12
|
+
- **Multi-turn conversations** — state accumulates across calls using the same
|
|
13
|
+
`thread_id`.
|
|
14
|
+
- **Fault tolerance** — if a graph fails mid-execution, the last checkpoint is
|
|
15
|
+
available to resume from.
|
|
16
|
+
- **Human-in-the-loop** — interrupts pause execution; the state is saved so a
|
|
17
|
+
human can inspect, modify, and resume.
|
|
18
|
+
- **Time travel** — list past checkpoints to replay or debug execution history.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## InMemorySaver
|
|
23
|
+
|
|
24
|
+
`GraphAgent::Checkpoint::InMemorySaver` stores checkpoints in a Ruby Hash. It
|
|
25
|
+
is suitable for development, testing, and single-process applications.
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
checkpointer = GraphAgent::Checkpoint::InMemorySaver.new
|
|
29
|
+
app = graph.compile(checkpointer: checkpointer)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> **Note:** Data is lost when the process exits. For production, implement a
|
|
33
|
+
> custom saver backed by a database (see below).
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Thread-Based Conversations
|
|
38
|
+
|
|
39
|
+
A **thread** is identified by `thread_id` in the config. All invocations with
|
|
40
|
+
the same `thread_id` share state:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
config = { configurable: { thread_id: "user-42" } }
|
|
44
|
+
|
|
45
|
+
# First turn
|
|
46
|
+
result1 = app.invoke(
|
|
47
|
+
{ messages: [{ role: "user", content: "Hi, I'm Alice" }] },
|
|
48
|
+
config: config
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Second turn — state includes messages from the first turn
|
|
52
|
+
result2 = app.invoke(
|
|
53
|
+
{ messages: [{ role: "user", content: "What's my name?" }] },
|
|
54
|
+
config: config
|
|
55
|
+
)
|
|
56
|
+
# result2[:messages] contains all messages from both turns
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Different `thread_id` values create independent conversations:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
config_a = { configurable: { thread_id: "thread-a" } }
|
|
63
|
+
config_b = { configurable: { thread_id: "thread-b" } }
|
|
64
|
+
|
|
65
|
+
# These are completely independent
|
|
66
|
+
app.invoke({ messages: [{ role: "user", content: "Hello" }] }, config: config_a)
|
|
67
|
+
app.invoke({ messages: [{ role: "user", content: "Bonjour" }] }, config: config_b)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Checkpoint Lifecycle
|
|
73
|
+
|
|
74
|
+
During execution, checkpoints are saved at specific points with a `source`
|
|
75
|
+
metadata field:
|
|
76
|
+
|
|
77
|
+
| Source | When |
|
|
78
|
+
|--------|------|
|
|
79
|
+
| `:input` | After initializing state from input, before any nodes run |
|
|
80
|
+
| `:loop` | After each superstep completes |
|
|
81
|
+
| `:interrupt` | When an interrupt fires (before or after a node) |
|
|
82
|
+
| `:exit` | After the graph finishes (reaches `END_NODE`) |
|
|
83
|
+
| `:update` | After a manual `update_state` call |
|
|
84
|
+
|
|
85
|
+
Each checkpoint stores:
|
|
86
|
+
|
|
87
|
+
- `channel_values` — the full state at that point.
|
|
88
|
+
- `next_nodes` — which nodes would execute next.
|
|
89
|
+
- `id` — a UUID identifying this checkpoint.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## get_state
|
|
94
|
+
|
|
95
|
+
Retrieve the current state snapshot for a thread:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
snapshot = app.get_state(config)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Returns a `GraphAgent::StateSnapshot` with:
|
|
102
|
+
|
|
103
|
+
| Attribute | Type | Description |
|
|
104
|
+
|-----------|------|-------------|
|
|
105
|
+
| `values` | Hash | Current state values |
|
|
106
|
+
| `next_nodes` | Array | Nodes that would execute next |
|
|
107
|
+
| `config` | Hash | Config including `checkpoint_id` |
|
|
108
|
+
| `metadata` | Hash | Step number, source, etc. |
|
|
109
|
+
| `parent_config` | Hash/nil | Config of the previous checkpoint |
|
|
110
|
+
| `tasks` | Array | Pending tasks |
|
|
111
|
+
| `interrupts` | Array | Active interrupts |
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
snapshot = app.get_state(config)
|
|
115
|
+
puts snapshot.values[:messages]
|
|
116
|
+
puts "Next: #{snapshot.next_nodes}"
|
|
117
|
+
puts "Step: #{snapshot.metadata[:step]}"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Returns `nil` if no checkpoint exists for the given config.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## update_state
|
|
125
|
+
|
|
126
|
+
Manually modify the state of a thread:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
app.update_state(config, { approved: true, reviewer: "Alice" })
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This creates a new checkpoint with the updated state. The metadata `source` is
|
|
133
|
+
set to `:update`.
|
|
134
|
+
|
|
135
|
+
`update_state` respects reducers: if a field has a reducer, the update value is
|
|
136
|
+
merged via that reducer, not replaced.
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
# If :messages has an ADD reducer:
|
|
140
|
+
app.update_state(config, {
|
|
141
|
+
messages: [{ role: "system", content: "Human approved this action" }]
|
|
142
|
+
})
|
|
143
|
+
# The new message is appended, not replaced
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Returns the new checkpoint config (with the new `checkpoint_id`), or `nil` if
|
|
147
|
+
no checkpoint exists.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## StateSnapshot
|
|
152
|
+
|
|
153
|
+
`GraphAgent::StateSnapshot` is a read-only object returned by `get_state`:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
snapshot = app.get_state(config)
|
|
157
|
+
|
|
158
|
+
snapshot.values # => { messages: [...], count: 5 }
|
|
159
|
+
snapshot.next_nodes # => ["process"]
|
|
160
|
+
snapshot.config # => { configurable: { thread_id: "t1", checkpoint_id: "..." } }
|
|
161
|
+
snapshot.metadata # => { source: :loop, step: 3 }
|
|
162
|
+
snapshot.parent_config # => { configurable: { ... } } or nil
|
|
163
|
+
snapshot.created_at # => Time or nil
|
|
164
|
+
snapshot.tasks # => []
|
|
165
|
+
snapshot.interrupts # => []
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Listing Checkpoints
|
|
171
|
+
|
|
172
|
+
`InMemorySaver#list` returns all checkpoints for a thread (most recent first):
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
checkpoints = checkpointer.list(config)
|
|
176
|
+
|
|
177
|
+
checkpoints.each do |tuple|
|
|
178
|
+
puts "ID: #{tuple.config.dig(:configurable, :checkpoint_id)}"
|
|
179
|
+
puts "Step: #{tuple.metadata[:step]}"
|
|
180
|
+
puts "Source: #{tuple.metadata[:source]}"
|
|
181
|
+
puts "---"
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Options:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# Filter by metadata
|
|
189
|
+
checkpointer.list(config, filter: { source: :loop })
|
|
190
|
+
|
|
191
|
+
# Limit results
|
|
192
|
+
checkpointer.list(config, limit: 5)
|
|
193
|
+
|
|
194
|
+
# Before a specific checkpoint
|
|
195
|
+
checkpointer.list(config, before: {
|
|
196
|
+
configurable: { checkpoint_id: "some-uuid" }
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Deleting Threads
|
|
203
|
+
|
|
204
|
+
Remove all checkpoints and writes for a thread:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
checkpointer.delete_thread("user-42")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Implementing a Custom CheckpointSaver
|
|
213
|
+
|
|
214
|
+
Subclass `GraphAgent::Checkpoint::BaseSaver` and implement four methods:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
class PostgresSaver < GraphAgent::Checkpoint::BaseSaver
|
|
218
|
+
def get_tuple(config)
|
|
219
|
+
thread_id = config.dig(:configurable, :thread_id)
|
|
220
|
+
checkpoint_id = config.dig(:configurable, :checkpoint_id)
|
|
221
|
+
|
|
222
|
+
# Query your database for the checkpoint
|
|
223
|
+
row = db_query(thread_id, checkpoint_id)
|
|
224
|
+
return nil unless row
|
|
225
|
+
|
|
226
|
+
GraphAgent::Checkpoint::CheckpointTuple.new(
|
|
227
|
+
config: config,
|
|
228
|
+
checkpoint: row[:checkpoint],
|
|
229
|
+
metadata: row[:metadata],
|
|
230
|
+
parent_config: row[:parent_config],
|
|
231
|
+
pending_writes: row[:pending_writes] || []
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def list(config, filter: nil, before: nil, limit: nil)
|
|
236
|
+
# Return an array of CheckpointTuple, newest first
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def put(config, checkpoint, metadata, new_versions)
|
|
240
|
+
thread_id = config.dig(:configurable, :thread_id)
|
|
241
|
+
checkpoint_id = checkpoint[:id]
|
|
242
|
+
|
|
243
|
+
# Insert into your database
|
|
244
|
+
db_insert(thread_id, checkpoint_id, checkpoint, metadata)
|
|
245
|
+
|
|
246
|
+
# Return the new config
|
|
247
|
+
{
|
|
248
|
+
configurable: {
|
|
249
|
+
thread_id: thread_id,
|
|
250
|
+
checkpoint_ns: config.dig(:configurable, :checkpoint_ns) || "",
|
|
251
|
+
checkpoint_id: checkpoint_id
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def put_writes(config, writes, task_id)
|
|
257
|
+
# Store pending writes
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def delete_thread(thread_id)
|
|
261
|
+
# Delete all data for the thread
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### CheckpointTuple
|
|
267
|
+
|
|
268
|
+
`GraphAgent::Checkpoint::CheckpointTuple` is a `Data.define` with these fields:
|
|
269
|
+
|
|
270
|
+
| Field | Type | Description |
|
|
271
|
+
|-------|------|-------------|
|
|
272
|
+
| `config` | Hash | Config with `thread_id`, `checkpoint_ns`, `checkpoint_id` |
|
|
273
|
+
| `checkpoint` | Hash | Serialized state (`channel_values`, `id`, etc.) |
|
|
274
|
+
| `metadata` | Hash/nil | Step, source, parents |
|
|
275
|
+
| `parent_config` | Hash/nil | Config pointing to the previous checkpoint |
|
|
276
|
+
| `pending_writes` | Array | Pending writes as `[task_id, channel, value]` triples |
|
data/docs/quickstart.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Quickstart
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
Add to your Gemfile:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "graph-agent"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Then run:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or install directly:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install graph-agent
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Ruby >= 3.1.0.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Hello World
|
|
28
|
+
|
|
29
|
+
The simplest possible graph: one node, in → out.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "graph_agent"
|
|
33
|
+
|
|
34
|
+
graph = GraphAgent::Graph::StateGraph.new({ greeting: {} })
|
|
35
|
+
|
|
36
|
+
graph.add_node("greet") do |state|
|
|
37
|
+
{ greeting: "Hello, #{state[:name]}!" }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
graph.add_edge(GraphAgent::START, "greet")
|
|
41
|
+
graph.add_edge("greet", GraphAgent::END_NODE)
|
|
42
|
+
|
|
43
|
+
app = graph.compile
|
|
44
|
+
result = app.invoke({ name: "World" })
|
|
45
|
+
|
|
46
|
+
puts result[:greeting]
|
|
47
|
+
# => "Hello, World!"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**What happened:**
|
|
51
|
+
|
|
52
|
+
1. We defined a state with a single field `:greeting` (last-value semantics — no reducer).
|
|
53
|
+
2. We added a node `"greet"` that reads `:name` from state and writes `:greeting`.
|
|
54
|
+
3. We wired `START → greet → END_NODE`.
|
|
55
|
+
4. We compiled and invoked.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Calculator Agent
|
|
60
|
+
|
|
61
|
+
A more realistic example: a loop that processes arithmetic operations until there are none left.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
require "graph_agent"
|
|
65
|
+
|
|
66
|
+
schema = GraphAgent::State::Schema.new do
|
|
67
|
+
field :operations, type: Array, reducer: GraphAgent::Reducers::REPLACE, default: []
|
|
68
|
+
field :results, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
|
|
69
|
+
field :done, type: :boolean, default: false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
graph = GraphAgent::Graph::StateGraph.new(schema)
|
|
73
|
+
|
|
74
|
+
graph.add_node("compute") do |state|
|
|
75
|
+
op = state[:operations].first
|
|
76
|
+
remaining = state[:operations][1..]
|
|
77
|
+
|
|
78
|
+
answer = case op[:op]
|
|
79
|
+
when "+" then op[:a] + op[:b]
|
|
80
|
+
when "-" then op[:a] - op[:b]
|
|
81
|
+
when "*" then op[:a] * op[:b]
|
|
82
|
+
when "/" then op[:a].to_f / op[:b]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
operations: remaining,
|
|
87
|
+
results: [{ expression: "#{op[:a]} #{op[:op]} #{op[:b]}", answer: answer }],
|
|
88
|
+
done: remaining.empty?
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
should_continue = ->(state) do
|
|
93
|
+
state[:done] ? GraphAgent::END_NODE.to_s : "compute"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
graph.add_edge(GraphAgent::START, "compute")
|
|
97
|
+
graph.add_conditional_edges("compute", should_continue)
|
|
98
|
+
|
|
99
|
+
app = graph.compile
|
|
100
|
+
|
|
101
|
+
result = app.invoke({
|
|
102
|
+
operations: [
|
|
103
|
+
{ op: "+", a: 2, b: 3 },
|
|
104
|
+
{ op: "*", a: 4, b: 5 },
|
|
105
|
+
{ op: "-", a: 10, b: 7 }
|
|
106
|
+
]
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
result[:results].each do |r|
|
|
110
|
+
puts "#{r[:expression]} = #{r[:answer]}"
|
|
111
|
+
end
|
|
112
|
+
# 2 + 3 = 5
|
|
113
|
+
# 4 * 5 = 20
|
|
114
|
+
# 10 - 7 = 3
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Running with Streaming
|
|
120
|
+
|
|
121
|
+
Instead of waiting for the final result, stream intermediate states:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
app.stream(
|
|
125
|
+
{ operations: [{ op: "+", a: 1, b: 2 }, { op: "*", a: 3, b: 4 }] },
|
|
126
|
+
stream_mode: :updates
|
|
127
|
+
) do |updates|
|
|
128
|
+
puts "Step updates: #{updates}"
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Or use an `Enumerator` (no block):
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
events = app.stream(
|
|
136
|
+
{ operations: [{ op: "+", a: 1, b: 2 }] },
|
|
137
|
+
stream_mode: :values
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
events.each do |state|
|
|
141
|
+
puts "Results so far: #{state[:results]}"
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Where to Go Next
|
|
148
|
+
|
|
149
|
+
- [Core Concepts](concepts.md) — understand graphs, state, nodes, edges, and supersteps.
|
|
150
|
+
- [State Management](state.md) — deep dive into schemas, reducers, and defaults.
|
|
151
|
+
- [Edges](edges.md) — all edge types including conditional and waiting edges.
|
|
152
|
+
- [Persistence](persistence.md) — checkpointing and multi-turn conversations.
|
|
153
|
+
- [Human-in-the-Loop](human_in_the_loop.md) — interrupt, inspect, modify, resume.
|
|
154
|
+
- [API Reference](api_reference.md) — every class and method.
|