ri-agent-graph 0.2.2

Graph-based agent orchestration for Rust — LangGraph-inspired execution engine with checkpointing, parallel fan-out/fan-in, interrupt/resume, and event streaming
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# ri-agent-graph

**Graph-based agent orchestration for Rust** — a LangGraph-inspired execution engine with checkpointing, parallel fan-out/fan-in, interrupt/resume, retry policies, and cryptographic execution receipts.

[![Crates.io](https://img.shields.io/crates/v/ri-agent-graph)](https://crates.io/crates/ri-agent-graph)
[![docs.rs](https://img.shields.io/docsrs/ri-agent-graph)](https://docs.rs/ri-agent-graph)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE-MIT)

![Architecture](assets/architecture.svg)

## What it gives you

- **Deterministic graph execution** — define nodes as computational steps, edges as control flow, and execute with typed state flowing through the graph
- **8 node types**`llm`, `router`, `join`, `parallel`, `passthrough`, `state_transform`, `subgraph`, `human_approval`
- **Parallel fan-out/fan-in** with configurable join policies: `collect_array`, `merge_objects`, `first_non_null`, `all_success`, `quorum`
- **Superstep execution loop** — dispatch → execute → checkpoint → advance, with automatic retry and cancellation
- **Checkpointing & interrupt/resume** — SQLite-backed persistence with atomic transactions, crash recovery, and checkpoint mismatch detection
- **Cryptographic receipts** — HMAC-SHA256 authenticated `GraphExecutionReceiptV1` with step-level digests and budget counters
- **Event streaming** — node lifecycle events, token streaming, state snapshots via `StreamExt`
- **Retry policies** — per-node retry with configurable backoff, max retries, and predicate filters
- **stack-ids integration**`TraceCtx`, `AttemptId`, `TrialId` at every layer for distributed tracing
- **Zero-cost abstractions** — generic over user-defined state `S`, no heap allocation beyond what your nodes require

![Lifecycle](assets/lifecycle.svg)

![Architecture Layers](assets/layers.svg)

## Installation

```bash
cargo add ri-agent-graph
```

Or in `Cargo.toml`:

```toml
[dependencies]
ri-agent-graph = "0.2"
```

### Feature flags

| Flag | Default | Description |
|------|---------|-------------|
| `checkpointing` | ✅ on | SQLite-backed persistence via `rusqlite` |

To run without persistence:

```toml
ri-agent-graph = { version = "0.2", default-features = false }
```

## Quick start

```rust
use ri_agent_graph::prelude::*;

#[tokio::main]
async fn main() -> Result<()> {
    let graph = AgentGraph::builder()
        .add_node("step1", node!(|state| async move {
            state.set("count", 1).await?;
            Ok(())
        }))
        .add_node("step2", node!(|state| async move {
            let count: i32 = state.get("count").await?;
            state.set("count", count + 1).await?;
            Ok(())
        }))
        .add_edge("step1", "step2")
        .build()?;

    let state = AgentState::new();
    let result = graph.execute("step1", state).await?;

    let final_count: i32 = result.get("count").await?;
    assert_eq!(final_count, 2);
    Ok(())
}
```

## Core concepts

### Graph & state model

The public API centers on three types:

- **`AgentGraph<S>`** — immutable graph definition: nodes + edges + reducers. Built with the builder pattern and validated at `.build()`.
- **`AgentState`** — key-value state (`serde_json::Value`) flowing through execution. Thread-safe via `Arc<RwLock<>>`.
- **`GraphExecutor<S>`** — the runtime engine. Wraps a graph and optional checkpoint store. Drives the superstep loop.

State is typed but flows as `serde_json::Value` internally, enabling heterogeneous workflows where different nodes operate on different state keys.

### Superstep execution loop

```
1. Dispatch  →  Route edges from current frontier to target nodes
2. Execute   →  Run all target nodes (parallel via JoinSet for fan-out)
3. Checkpoint →  Save attempt outcomes to SQLite (if enabled)
4. Advance   →  Set new frontier; halt if END sentinel reached
5. Repeat    →  Guarded by max_iterations; retry on failure with policy
```

### State management

```rust
// Set/get typed values
state.set("name", "agent-graph").await?;
state.set("count", 42).await?;
let name: String = state.get("name").await?;
let count: i32 = state.get("count").await?;

// Optional access
let maybe: Option<String> = state.get_opt("missing").await?;

// Check existence
if state.contains("name").await? {
    // ...
}

// List all keys
let keys: Vec<String> = state.keys().await;

// Remove a key
state.remove("temp").await?;

// Snapshot & restore
let snapshot = state.snapshot().await;
state.restore(&snapshot).await?;
```

### State limits

```rust
let graph = AgentGraph::builder()
    .with_state_limits(StateLimits {
        max_keys: 100,
        max_value_bytes: 1024 * 1024, // 1MB
    })
    .build()?;
```

## Node types

| Type | Description | Status |
|------|-------------|--------|
| `llm` | Invoke an LLM via `Payload` trait. Response merged via reducer. ||
| `router` | Conditional branching. Evaluates a predicate to select next edges dynamically. ||
| `join` | Fan-in synchronization. Waits for all parallel branches, merges state. ||
| `parallel` | Fan-out dispatch. Engine's `JoinSet` handles real concurrent execution. ||
| `passthrough` | No-op pass. Useful for fan-out distribution points between coordinator and workers. ||
| `state_transform` | 10 declarative state mutations: `set`, `copy`, `delete`, `increment`, `append`, `merge`, `merge_object`, `select`, `compare`, `format`. ||
| `subgraph` | Reference another registered graph as a composable sub-workflow. ||
| `human_approval` | HITL gate. Emits `InterruptError`; resumes via checkpoint injection. ||

## Router example

```rust
use ri_agent_graph::{AgentGraph, node, START, END};
use serde_json::json;

let graph = AgentGraph::builder()
    .add_node("classify", node!(|state| async move {
        state.set("category", "bug").await?;
        Ok(())
    }))
    .add_node("handle_bug", node!(|state| async move {
        state.set("response", "Bug triaged").await?;
        Ok(())
    }))
    .add_node("handle_feature", node!(|state| async move {
        state.set("response", "Feature scoped").await?;
        Ok(())
    }))
    .add_node("handle_question", node!(|state| async move {
        state.set("response", "Question answered").await?;
        Ok(())
    }))
    .add_edge(START, "classify")
    .add_router("classify", router!(|state| {
        let category: String = state.get("category").await?;
        Ok(match category.as_str() {
            "bug" => vec!["handle_bug"],
            "feature" => vec!["handle_feature"],
            _ => vec!["handle_question"],
        })
    }))
    .add_edge("handle_bug", END)
    .add_edge("handle_feature", END)
    .add_edge("handle_question", END)
    .build()?;
```

## Parallel fan-out with join

```rust
let graph = AgentGraph::builder()
    .add_node("coordinator", node!(|state| async move {
        state.set("workstreams", json!(["A", "B", "C"])).await?;
        Ok(())
    }))
    .add_node("fanout", passthrough_node!())
    .add_node("worker_a", node!(|state| async move {
        state.set("result_a", "done").await?;
        Ok(())
    }))
    .add_node("worker_b", node!(|state| async move {
        state.set("result_b", "done").await?;
        Ok(())
    }))
    .add_node("worker_c", node!(|state| async move {
        state.set("result_c", "done").await?;
        Ok(())
    }))
    .add_node("merger", join_node!(JoinMode::CollectArray,
        ["result_a", "result_b", "result_c"], "findings"))
    .add_edge("coordinator", "fanout")
    .add_edge("fanout", "worker_a")
    .add_edge("fanout", "worker_b")
    .add_edge("fanout", "worker_c")
    .add_edge("worker_a", "merger")
    .add_edge("worker_b", "merger")
    .add_edge("worker_c", "merger")
    .add_edge("merger", END)
    .with_reducers(Reducers::new().append_to("findings"))
    .build()?;
```

## Reducers

When parallel branches write to the same state key, a reducer resolves the conflict:

```rust
use ri_agent_graph::reducer::Reducer;

Reducers::new()
    .append_to("findings")          // Concatenate arrays
    .merge_into("metadata")          // Deep-merge objects
    .with("counter", Reducer::Add)   // Numeric addition
    .with("latest", Reducer::LastWriteWins)
    .with_fn("custom", |existing, incoming| {
        // Your merge logic here
        Ok(incoming)
    });
```

## Checkpointing & interrupt/resume

```rust
use ri_agent_graph::checkpoint_store::SqliteCheckpointStore;

let store = SqliteCheckpointStore::open("executions.db").await?;
let executor = GraphExecutor::new(graph)
    .with_checkpoint_store(store);

match executor.execute_with_interrupt(state).await {
    Ok(receipt) => println!("Completed: {:?}", receipt.run_id),
    Err(AgentGraphError::Interrupted { checkpoint_id, .. }) => {
        // Inject new input and resume from exact checkpoint
        executor.resume_from(checkpoint_id, injected_input).await?;
    }
}
```

### Retry on failure

```rust
use ri_agent_graph::retry::RetryPolicy;

let graph = AgentGraph::builder()
    .add_node("flaky_api", node!(|state| async move {
        // ...
        Ok(())
    }))
    .with_retry_policy("flaky_api", RetryPolicy::new()
        .max_retries(3)
        .backoff(Duration::from_millis(100), Duration::from_secs(5))
        .retry_if(|err| err.to_string().contains("timeout")))
    .build()?;
```

## Execution receipts

Every run produces a `GraphExecutionReceiptV1`:

```rust
pub struct GraphExecutionReceiptV1 {
    pub run_id: String,
    pub graph_name: String,
    pub start_time: DateTime<Utc>,
    pub end_time: DateTime<Utc>,
    pub steps: Vec<StepExecutionReceiptV1>,
    pub final_state_digest: String,
    pub status: ExecutionOutcome,  // Completed | Failed | Interrupted | Cancelled
}
```

Each `StepExecutionReceiptV1`:
- `node_id` — which node executed
- `attempt` — attempt number (0-based)
- `duration_ms` — wall-clock duration
- `input_digest` / `output_digest` — state hashes before/after
- `error` — error details if the node failed
- `trace_ctx` / `attempt_id` / `trial_id` — from `stack-ids`

## Error handling

```rust
pub enum AgentGraphError {
    // Build errors
    GraphBuild(String),
    NodeNotFound(String),
    EdgeNotFound(String),
    DuplicateNode(String),

    // Runtime errors
    StateKeyNotFound(String),
    StateTypeMismatch { key: String, expected: String, actual: String },
    ParallelWriteConflict(String),

    // Limits
    StateLimitExceeded { key: String, limit: usize, actual: usize },
    MaxIterationsExceeded { max: usize },

    // Checkpoint
    CheckpointError(CheckpointStoreOperation),
    CheckpointMismatch { expected: String, actual: String },

    // Lifecycle
    Interrupted { checkpoint_id: String, node_id: String },
    ExecutionTimeout { run_id: String, elapsed_ms: u64 },
    Cancelled { run_id: String },

    // Other
    IntegrityKeyRequired,
    Internal(String),
}
```

All fallible operations return `Result<T, AgentGraphError>`.

## Event streaming

```rust
use futures::StreamExt;

let executor = GraphExecutor::new(graph);
let mut stream = executor.execute_stream("entry", state).await?;

while let Some(event) = stream.next().await {
    match event {
        StreamEvent::NodeStarted { node_id, attempt, .. } => {},
        StreamEvent::NodeCompleted { node_id, duration_ms, .. } => {},
        StreamEvent::TokenStream { node_id, token } => {},
        StreamEvent::StateSnapshot { state } => {},
        StreamEvent::Error { node_id, error } => {},
    }
}
```

## Ecosystem

| Crate | Description | Version |
|-------|-------------|---------|
| [ri-agent-graph]https://crates.io/crates/ri-agent-graph | Core graph execution engine (this crate) | v0.2.1 |
| [agent-graph-mcp]https://crates.io/crates/agent-graph-mcp | MCP server — 25 typed tools for graph lifecycle, execution, approval, templates | v0.2.2 |
| [stack-ids]https://crates.io/crates/stack-ids | Shared identity, scope, and trace primitives | v0.1.3 |
| [llm-pipeline]https://crates.io/crates/llm-pipeline | Reusable LLM node payloads (Ollama, prompt templating, parsing) | v0.2.0 |

## Comparison

| Feature | ri-agent-graph | LangGraph (Python) | LangGraph (JS) |
|---------|:---:|:---:|:---:|
| Language | Rust | Python | TypeScript |
| Parallel fan-out | ✅ JoinSet |||
| Checkpointing | ✅ SQLite | ✅ Postgres/SQLite | ✅ Postgres/SQLite |
| Interrupt/resume | ✅ Deterministic | ✅ Full | ✅ Full |
| Retry policies | ✅ Per-node | ✅ Per-node | ✅ Per-node |
| Event streaming | ✅ StreamExt |||
| Cryptographic receipts | ✅ HMAC-SHA256 |||
| MCP protocol server | ✅ Built-in |||
| Zero-copy state | ✅ serde_json::Value | ❌ Python dict | ❌ JS object |

## Claim boundaries

- **Graph execution semantics only** — this crate does not include LLM provider clients, prompt templating, or response parsing. Those belong in `llm-pipeline` or your application layer.
- **Receipts prove structural execution** — they carry cryptographic digests of the local execution trace only. They do not prove that an external LLM call occurred or what any provider's internal state was.
- **Interrupt/resume is deterministic local** — supports linear chains of deterministic `passthrough` and `state_transform` nodes with SQLite-bound state. It does not support resuming across LLM calls, network I/O, or external tool invocations.
- **Parallelism is best-effort** — uses Tokio's `JoinSet`. Unordered parallel writes to the same state key are rejected unless an explicit `Reducer` is declared.

## Verification

```bash
cargo build --release -p ri-agent-graph
cargo test -p ri-agent-graph          # 149 tests
cargo clippy -p ri-agent-graph -- -D warnings
cargo fmt --check
cargo publish -p ri-agent-graph --dry-run
```

## Roadmap

- [ ] Typed state extractors (derive macro for `StateExtract`)
- [ ] Graph visualization (Mermaid/DOT export from `graph_inspect`)
- [ ] Streaming LLM token passthrough to event stream
- [ ] Distributed checkpoint backends (PostgreSQL, S3)
- [ ] Subgraph composition with isolated state namespaces
- [ ] WebAssembly target (`wasm-bindgen`, no_std without checkpointing)
- [ ] Generic replay for non-deterministic node types

## License

MIT — see [LICENSE-MIT](LICENSE-MIT).

---

Built by [RecursiveIntell](https://github.com/RecursiveIntell) — an applied R&D studio building local-first AI infrastructure.