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,218 @@
|
|
|
1
|
+
# Send & Command
|
|
2
|
+
|
|
3
|
+
GraphAgent provides two types for advanced routing beyond simple edges:
|
|
4
|
+
`Send` for fan-out/fan-in (map-reduce) patterns, and `Command` for combining
|
|
5
|
+
state updates with routing decisions.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Send
|
|
10
|
+
|
|
11
|
+
`GraphAgent::Send` dispatches work to a specific node with custom input. It is
|
|
12
|
+
primarily used inside conditional edge functions to create **map-reduce**
|
|
13
|
+
patterns.
|
|
14
|
+
|
|
15
|
+
### Constructor
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
GraphAgent::Send.new(node, arg)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Parameter | Type | Description |
|
|
22
|
+
|-----------|------|-------------|
|
|
23
|
+
| `node` | String/Symbol | Target node name |
|
|
24
|
+
| `arg` | Hash | State updates to apply before executing the target node |
|
|
25
|
+
|
|
26
|
+
### Fan-Out (Map)
|
|
27
|
+
|
|
28
|
+
Return an array of `Send` objects from a conditional edge to fan out work
|
|
29
|
+
across multiple invocations of the same (or different) nodes:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
schema = GraphAgent::State::Schema.new do
|
|
33
|
+
field :subjects, type: Array, default: []
|
|
34
|
+
field :jokes, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
graph = GraphAgent::Graph::StateGraph.new(schema)
|
|
38
|
+
|
|
39
|
+
graph.add_node("get_subjects") do |state|
|
|
40
|
+
{ subjects: ["cats", "dogs", "programming"] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
graph.add_node("generate_joke") do |state|
|
|
44
|
+
subject = state[:subjects].first
|
|
45
|
+
{ jokes: ["Why did the #{subject} cross the road? ..."] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
fan_out = ->(state) do
|
|
49
|
+
state[:subjects].map do |subject|
|
|
50
|
+
GraphAgent::Send.new("generate_joke", { subjects: [subject] })
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
graph.add_edge(GraphAgent::START, "get_subjects")
|
|
55
|
+
graph.add_conditional_edges("get_subjects", fan_out)
|
|
56
|
+
graph.add_edge("generate_joke", GraphAgent::END_NODE)
|
|
57
|
+
|
|
58
|
+
app = graph.compile
|
|
59
|
+
result = app.invoke({})
|
|
60
|
+
puts result[:jokes]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Each `Send` triggers a separate execution of `"generate_joke"` with a different
|
|
64
|
+
subject. All results are aggregated back via the `:jokes` reducer.
|
|
65
|
+
|
|
66
|
+
### Fan-In (Reduce)
|
|
67
|
+
|
|
68
|
+
Fan-in happens automatically: all `Send` targets write to the same shared
|
|
69
|
+
state, and reducers aggregate the results. You can also use
|
|
70
|
+
[waiting edges](edges.md#waiting-edges-multi-source-synchronization) to
|
|
71
|
+
synchronize before a downstream node.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Command
|
|
76
|
+
|
|
77
|
+
`GraphAgent::Command` combines a **state update** with a **routing decision**
|
|
78
|
+
in a single return value from a node.
|
|
79
|
+
|
|
80
|
+
### Constructor
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
GraphAgent::Command.new(
|
|
84
|
+
graph: nil, # reserved for subgraph routing
|
|
85
|
+
update: nil, # Hash of state updates to apply
|
|
86
|
+
resume: nil, # resume value (for interrupt workflows)
|
|
87
|
+
goto: [] # String, Symbol, Send, or Array thereof — next node(s)
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Basic Usage
|
|
92
|
+
|
|
93
|
+
Return a `Command` from a node to update state and route simultaneously:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
graph.add_node("router") do |state|
|
|
97
|
+
target = state[:intent] == "buy" ? "purchase" : "browse"
|
|
98
|
+
GraphAgent::Command.new(
|
|
99
|
+
update: { routed: true, route: target },
|
|
100
|
+
goto: target
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Goto with Multiple Targets
|
|
106
|
+
|
|
107
|
+
`goto` accepts an array to route to multiple nodes in the next step:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
graph.add_node("fork") do |state|
|
|
111
|
+
GraphAgent::Command.new(
|
|
112
|
+
goto: ["analyze", "log"]
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Goto with Send
|
|
118
|
+
|
|
119
|
+
You can mix `Send` objects in `goto` for dynamic fan-out:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
graph.add_node("dispatch") do |state|
|
|
123
|
+
sends = state[:tasks].map do |task|
|
|
124
|
+
GraphAgent::Send.new("worker", { current_task: task })
|
|
125
|
+
end
|
|
126
|
+
GraphAgent::Command.new(goto: sends)
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Update Without Routing
|
|
131
|
+
|
|
132
|
+
If you only need state updates (with routing handled by normal edges), use a
|
|
133
|
+
plain Hash return instead of `Command`. Use `Command` specifically when you
|
|
134
|
+
need the node to **control routing**.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## When to Use Command vs Conditional Edges
|
|
139
|
+
|
|
140
|
+
| Use Case | Recommended |
|
|
141
|
+
|----------|-------------|
|
|
142
|
+
| Route based on state, node doesn't modify state | Conditional edge |
|
|
143
|
+
| Route based on state **and** modify state atomically | `Command` |
|
|
144
|
+
| Fan-out to multiple instances of the same node | `Send` via conditional edge |
|
|
145
|
+
| Fan-out with per-instance state modifications | `Command` with `Send` in `goto` |
|
|
146
|
+
| Simple sequential flow | Normal edge |
|
|
147
|
+
|
|
148
|
+
### Command vs Conditional Edge Example
|
|
149
|
+
|
|
150
|
+
**Conditional edge** — routing logic is separate from the node:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
graph.add_node("classify") do |state|
|
|
154
|
+
{ category: detect_category(state[:input]) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
graph.add_conditional_edges("classify", ->(state) {
|
|
158
|
+
state[:category]
|
|
159
|
+
}, { "a" => "handler_a", "b" => "handler_b" })
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Command** — routing logic is inside the node:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
graph.add_node("classify") do |state|
|
|
166
|
+
category = detect_category(state[:input])
|
|
167
|
+
GraphAgent::Command.new(
|
|
168
|
+
update: { category: category },
|
|
169
|
+
goto: "handler_#{category}"
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Still need outgoing edges for validation — use conditional edges
|
|
174
|
+
# that won't actually be reached, or structure so that Command targets
|
|
175
|
+
# are valid node names.
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Complete Map-Reduce Example
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
require "graph_agent"
|
|
184
|
+
|
|
185
|
+
schema = GraphAgent::State::Schema.new do
|
|
186
|
+
field :urls, type: Array, default: []
|
|
187
|
+
field :pages, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
|
|
188
|
+
field :summary, type: String
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
graph = GraphAgent::Graph::StateGraph.new(schema)
|
|
192
|
+
|
|
193
|
+
graph.add_node("plan") do |state|
|
|
194
|
+
{ urls: ["https://example.com/a", "https://example.com/b"] }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
graph.add_node("fetch") do |state|
|
|
198
|
+
url = state[:urls].first
|
|
199
|
+
{ pages: ["Content from #{url}"] }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
graph.add_node("summarize") do |state|
|
|
203
|
+
{ summary: "Summarized #{state[:pages].length} pages" }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
fan_out_fetches = ->(state) do
|
|
207
|
+
state[:urls].map { |url| GraphAgent::Send.new("fetch", { urls: [url] }) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
graph.add_edge(GraphAgent::START, "plan")
|
|
211
|
+
graph.add_conditional_edges("plan", fan_out_fetches)
|
|
212
|
+
graph.add_edge("fetch", "summarize")
|
|
213
|
+
graph.add_edge("summarize", GraphAgent::END_NODE)
|
|
214
|
+
|
|
215
|
+
app = graph.compile
|
|
216
|
+
result = app.invoke({})
|
|
217
|
+
puts result[:summary]
|
|
218
|
+
```
|
data/docs/state.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# State Management
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
State is the shared data structure that every node reads from and writes to.
|
|
6
|
+
GraphAgent applies updates atomically after each superstep using **reducers**.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Defining State with Schema DSL
|
|
11
|
+
|
|
12
|
+
Use `GraphAgent::State::Schema` with a block:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
schema = GraphAgent::State::Schema.new do
|
|
16
|
+
field :messages, type: Array, reducer: GraphAgent::Reducers::ADD, default: []
|
|
17
|
+
field :count, type: Integer, reducer: GraphAgent::Reducers::ADD, default: 0
|
|
18
|
+
field :status, type: String
|
|
19
|
+
field :metadata, type: Hash, reducer: GraphAgent::Reducers::MERGE, default: {}
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each `field` call accepts:
|
|
24
|
+
|
|
25
|
+
| Parameter | Type | Description |
|
|
26
|
+
|-----------|------|-------------|
|
|
27
|
+
| `name` | Symbol/String | Field name (converted to Symbol) |
|
|
28
|
+
| `type:` | Class/nil | Optional type annotation (not enforced at runtime) |
|
|
29
|
+
| `reducer:` | Proc/nil | Callable `(current, new) → merged`; nil means last-value semantics |
|
|
30
|
+
| `default:` | Object/nil | Initial value; duplicated per invocation to avoid shared state |
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Defining State with Hash Shorthand
|
|
35
|
+
|
|
36
|
+
Pass a Hash directly to `StateGraph.new` instead of a `Schema` object:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
graph = GraphAgent::Graph::StateGraph.new({
|
|
40
|
+
messages: { type: Array, reducer: ->(a, b) { a + b }, default: [] },
|
|
41
|
+
count: { type: Integer, reducer: ->(a, b) { a + b }, default: 0 },
|
|
42
|
+
status: {} # last-value semantics, no default
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Each key becomes a field name. Values can be:
|
|
47
|
+
|
|
48
|
+
- A **Hash** with `:type`, `:reducer`, `:default` keys (all optional).
|
|
49
|
+
- Any non-Hash value is treated as a field with no options.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Built-in Reducers
|
|
54
|
+
|
|
55
|
+
Defined in `GraphAgent::Reducers`:
|
|
56
|
+
|
|
57
|
+
| Reducer | Lambda | Behavior |
|
|
58
|
+
|---------|--------|----------|
|
|
59
|
+
| `ADD` | `(a, b) → a + b` | Concatenates arrays, adds numbers, joins strings |
|
|
60
|
+
| `APPEND` | `(a, b) → Array(a) + Array(b)` | Wraps both sides in arrays then concatenates |
|
|
61
|
+
| `MERGE` | `(a, b) → a.merge(b)` | Shallow-merges hashes |
|
|
62
|
+
| `REPLACE` | `(_, b) → b` | Always replaces with the new value |
|
|
63
|
+
|
|
64
|
+
### `add_messages`
|
|
65
|
+
|
|
66
|
+
A special reducer for chat message lists. It matches messages by `:id` and
|
|
67
|
+
replaces existing messages in-place; new messages (without matching IDs) are
|
|
68
|
+
appended:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
field :messages, reducer: GraphAgent::Reducers.method(:add_messages), default: []
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
existing = [
|
|
78
|
+
{ id: "1", role: "user", content: "Hi" },
|
|
79
|
+
{ id: "2", role: "ai", content: "Hello" }
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
new_msgs = [
|
|
83
|
+
{ id: "2", role: "ai", content: "Hello! How can I help?" }, # updates id "2"
|
|
84
|
+
{ id: "3", role: "user", content: "Tell me a joke" } # appended
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
result = GraphAgent::Reducers.add_messages(existing, new_msgs)
|
|
88
|
+
# => [
|
|
89
|
+
# { id: "1", role: "user", content: "Hi" },
|
|
90
|
+
# { id: "2", role: "ai", content: "Hello! How can I help?" },
|
|
91
|
+
# { id: "3", role: "user", content: "Tell me a joke" }
|
|
92
|
+
# ]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Custom Reducers
|
|
98
|
+
|
|
99
|
+
Any callable that takes two arguments works as a reducer:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# Keep only the last N messages
|
|
103
|
+
keep_last_10 = ->(existing, new_msgs) do
|
|
104
|
+
(Array(existing) + Array(new_msgs)).last(10)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
schema = GraphAgent::State::Schema.new do
|
|
108
|
+
field :messages, reducer: keep_last_10, default: []
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# Union of sets
|
|
114
|
+
set_union = ->(a, b) { (Array(a) | Array(b)) }
|
|
115
|
+
|
|
116
|
+
schema = GraphAgent::State::Schema.new do
|
|
117
|
+
field :tags, reducer: set_union, default: []
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Initial State and Defaults
|
|
124
|
+
|
|
125
|
+
When a graph is invoked, `Schema#initial_state` produces the starting state by
|
|
126
|
+
duplicating each field's default value:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
schema = GraphAgent::State::Schema.new do
|
|
130
|
+
field :items, default: []
|
|
131
|
+
field :count, default: 0
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
schema.initial_state
|
|
135
|
+
# => { items: [], count: 0 }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- Defaults are `.dup`'d to prevent shared mutation between invocations.
|
|
139
|
+
- Fields without a default start as `nil`.
|
|
140
|
+
- Input values passed to `invoke` are merged on top of the initial state.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## How Updates Are Applied
|
|
145
|
+
|
|
146
|
+
After each superstep, the compiled graph calls `Schema#apply` (or the internal
|
|
147
|
+
`_apply_updates` method) for every update returned by the nodes:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# Pseudocode for one superstep:
|
|
151
|
+
# 1. All nodes run on a frozen snapshot
|
|
152
|
+
# 2. Collect updates: { "node_a" => { count: 1 }, "node_b" => { count: 2 } }
|
|
153
|
+
# 3. For each update hash, for each key:
|
|
154
|
+
# if field has a reducer → state[key] = reducer.call(state[key], value)
|
|
155
|
+
# else → state[key] = value (last-value)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
This means:
|
|
159
|
+
|
|
160
|
+
- **With a reducer** (`ADD`), two nodes returning `{ count: 1 }` and `{ count: 2 }`
|
|
161
|
+
produce `count = 0 + 1 + 2 = 3` (assuming default 0).
|
|
162
|
+
- **Without a reducer**, the last update wins (order depends on iteration of
|
|
163
|
+
the updates hash).
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Private / Input / Output Schemas
|
|
168
|
+
|
|
169
|
+
`StateGraph` accepts `input_schema:` and `output_schema:` parameters:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
graph = GraphAgent::Graph::StateGraph.new(
|
|
173
|
+
full_schema,
|
|
174
|
+
input_schema: input_only_schema,
|
|
175
|
+
output_schema: output_only_schema
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
These are stored on the builder for future use (e.g., input validation and
|
|
180
|
+
output filtering) but are not yet enforced at runtime. The main `schema`
|
|
181
|
+
parameter defines all fields the graph operates on.
|
data/docs/streaming.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Streaming
|
|
2
|
+
|
|
3
|
+
GraphAgent supports streaming intermediate results as the graph executes,
|
|
4
|
+
instead of waiting for the final output.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Stream Modes
|
|
9
|
+
|
|
10
|
+
The `stream` method accepts a `stream_mode` parameter:
|
|
11
|
+
|
|
12
|
+
| Mode | Yields | Description |
|
|
13
|
+
|------|--------|-------------|
|
|
14
|
+
| `:values` | Full state Hash | Emits the complete state after each superstep |
|
|
15
|
+
| `:updates` | Per-node updates Hash | Emits only the changes each node produced |
|
|
16
|
+
| `:debug` | Raw event Hash | Emits all internal events with type, step, state, and updates |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## stream_mode: :values
|
|
21
|
+
|
|
22
|
+
Yields the full state after each superstep. This is the default mode.
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
app.stream({ query: "hello" }, stream_mode: :values) do |state|
|
|
26
|
+
puts "Messages: #{state[:messages].length}"
|
|
27
|
+
puts "Status: #{state[:status]}"
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The final yield contains the complete output state (same as what `invoke`
|
|
32
|
+
would return).
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## stream_mode: :updates
|
|
37
|
+
|
|
38
|
+
Yields only the updates produced by each node in the step:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
app.stream({ query: "hello" }, stream_mode: :updates) do |updates|
|
|
42
|
+
updates.each do |node_name, node_updates|
|
|
43
|
+
puts "#{node_name} produced: #{node_updates}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The updates hash maps node names (strings) to the Hash each node returned.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## stream_mode: :debug
|
|
53
|
+
|
|
54
|
+
Yields raw event hashes with full internal detail:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
app.stream({ query: "hello" }, stream_mode: :debug) do |event|
|
|
58
|
+
case event[:type]
|
|
59
|
+
when :values
|
|
60
|
+
puts "Step #{event[:step]}: state = #{event[:state]}"
|
|
61
|
+
when :updates
|
|
62
|
+
puts "Step #{event[:step]}: updates = #{event[:updates]}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Event structure:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
{
|
|
71
|
+
type: :values | :updates,
|
|
72
|
+
step: Integer,
|
|
73
|
+
state: Hash, # present for :values events
|
|
74
|
+
updates: Hash # present for :updates events
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Using Block Form
|
|
81
|
+
|
|
82
|
+
Pass a block to `stream` to process events as they arrive:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
app.stream(input, config: config, stream_mode: :values) do |state|
|
|
86
|
+
render_state(state)
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Using Enumerator (No Block)
|
|
93
|
+
|
|
94
|
+
When called without a block, `stream` returns an `Enumerator`:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
events = app.stream(input, stream_mode: :values)
|
|
98
|
+
|
|
99
|
+
events.each do |state|
|
|
100
|
+
puts state[:messages].last
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The `Enumerator` is lazy — events are produced as the graph executes. You can
|
|
105
|
+
use standard Enumerable methods:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# Get the first 3 states
|
|
109
|
+
first_three = app.stream(input, stream_mode: :values).take(3)
|
|
110
|
+
|
|
111
|
+
# Find the first state where processing is complete
|
|
112
|
+
done = app.stream(input, stream_mode: :values).find { |s| s[:done] }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Streaming with Config
|
|
118
|
+
|
|
119
|
+
All `stream` options from `invoke` are available:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
app.stream(
|
|
123
|
+
input,
|
|
124
|
+
config: { configurable: { thread_id: "t1" } },
|
|
125
|
+
recursion_limit: 50,
|
|
126
|
+
stream_mode: :updates
|
|
127
|
+
) do |updates|
|
|
128
|
+
puts updates
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Complete Example
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
require "graph_agent"
|
|
138
|
+
|
|
139
|
+
schema = GraphAgent::State::Schema.new do
|
|
140
|
+
field :numbers, type: Array, default: []
|
|
141
|
+
field :sum, type: Integer, reducer: GraphAgent::Reducers::ADD, default: 0
|
|
142
|
+
field :step_name, type: String
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
graph = GraphAgent::Graph::StateGraph.new(schema)
|
|
146
|
+
|
|
147
|
+
graph.add_node("add_evens") do |state|
|
|
148
|
+
evens = state[:numbers].select(&:even?)
|
|
149
|
+
{ sum: evens.sum, step_name: "add_evens" }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
graph.add_node("add_odds") do |state|
|
|
153
|
+
odds = state[:numbers].select(&:odd?)
|
|
154
|
+
{ sum: odds.sum, step_name: "add_odds" }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
graph.add_edge(GraphAgent::START, "add_evens")
|
|
158
|
+
graph.add_edge("add_evens", "add_odds")
|
|
159
|
+
graph.add_edge("add_odds", GraphAgent::END_NODE)
|
|
160
|
+
|
|
161
|
+
app = graph.compile
|
|
162
|
+
|
|
163
|
+
puts "=== :values mode ==="
|
|
164
|
+
app.stream({ numbers: [1, 2, 3, 4, 5] }, stream_mode: :values) do |state|
|
|
165
|
+
puts "sum=#{state[:sum]} step=#{state[:step_name]}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
puts "\n=== :updates mode ==="
|
|
169
|
+
app.stream({ numbers: [1, 2, 3, 4, 5] }, stream_mode: :updates) do |updates|
|
|
170
|
+
updates.each { |node, u| puts "#{node}: #{u}" }
|
|
171
|
+
end
|
|
172
|
+
```
|
data/graph-agent.gemspec
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/graph_agent/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "graph-agent"
|
|
7
|
+
spec.version = GraphAgent::VERSION
|
|
8
|
+
spec.authors = ["GraphAgent Contributors"]
|
|
9
|
+
spec.email = ["richard.sun@ai-firstly.com"]
|
|
10
|
+
spec.summary = "A Ruby framework for building stateful, multi-actor agent workflows"
|
|
11
|
+
spec.description = "Ruby port of LangGraph - build stateful, multi-actor applications " \
|
|
12
|
+
"with LLMs using a graph-based workflow engine with Pregel execution model"
|
|
13
|
+
spec.homepage = "https://github.com/ai-firstly/graph-agent"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = [">= 3.1.0", "< 5.0"]
|
|
16
|
+
|
|
17
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["source_code_uri"] = "https://github.com/ai-firstly/graph-agent"
|
|
20
|
+
spec.metadata["changelog_uri"] = "https://github.com/ai-firstly/graph-agent/blob/main/CHANGELOG.md"
|
|
21
|
+
spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/graph-agent"
|
|
22
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/ai-firstly/graph-agent/issues"
|
|
23
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
24
|
+
|
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
26
|
+
if File.exist?(".git")
|
|
27
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
28
|
+
f.match(%r{\A(?:test|spec|features)/})
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
Dir.glob("**/*").reject do |f|
|
|
32
|
+
File.directory?(f) ||
|
|
33
|
+
f.match(%r{\A(?:test|spec|features)/}) ||
|
|
34
|
+
f.match(/\A\./) ||
|
|
35
|
+
f.match(/\.gem$/)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
spec.bindir = "exe"
|
|
40
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
41
|
+
spec.require_paths = ["lib"]
|
|
42
|
+
|
|
43
|
+
spec.add_dependency "concurrent-ruby", "~> 1.2"
|
|
44
|
+
|
|
45
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
46
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
47
|
+
spec.add_development_dependency "rubocop", "~> 1.50"
|
|
48
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphAgent
|
|
4
|
+
module Channels
|
|
5
|
+
class BaseChannel
|
|
6
|
+
MISSING = Object.new.freeze
|
|
7
|
+
|
|
8
|
+
attr_accessor :key
|
|
9
|
+
|
|
10
|
+
def initialize(key: "")
|
|
11
|
+
@key = key
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get
|
|
15
|
+
raise NotImplementedError.new("#{self.class}#get must be implemented")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def update(values)
|
|
19
|
+
raise NotImplementedError.new("#{self.class}#update must be implemented")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def checkpoint
|
|
23
|
+
get
|
|
24
|
+
rescue EmptyChannelError
|
|
25
|
+
MISSING
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def from_checkpoint(checkpoint)
|
|
29
|
+
raise NotImplementedError.new("#{self.class}#from_checkpoint must be implemented")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def available?
|
|
33
|
+
get
|
|
34
|
+
true
|
|
35
|
+
rescue EmptyChannelError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def consume
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def finish
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def copy
|
|
48
|
+
from_checkpoint(checkpoint)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|