rillpy 0.1.0__py3-none-any.whl
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.
- rill/__init__.py +3 -0
- rill/flow.py +489 -0
- rill/utils/__init__.py +0 -0
- rill/utils/logger.py +133 -0
- rillpy-0.1.0.dist-info/METADATA +478 -0
- rillpy-0.1.0.dist-info/RECORD +9 -0
- rillpy-0.1.0.dist-info/WHEEL +5 -0
- rillpy-0.1.0.dist-info/licenses/LICENSE +21 -0
- rillpy-0.1.0.dist-info/top_level.txt +1 -0
rill/__init__.py
ADDED
rill/flow.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""DAG-based flow orchestration with parallel execution and dynamic routing.
|
|
2
|
+
|
|
3
|
+
Typical usage:
|
|
4
|
+
class MyFlow(Flow):
|
|
5
|
+
@node(start=True, goto="process")
|
|
6
|
+
def start(self, inputs):
|
|
7
|
+
return {"data": inputs}
|
|
8
|
+
|
|
9
|
+
@node(goto=DYNAMIC)
|
|
10
|
+
async def process(self, inputs):
|
|
11
|
+
if needs_parallel:
|
|
12
|
+
return goto([self.taskA, self.taskB], inputs) # parallel
|
|
13
|
+
return goto(self.finalize, inputs) # single path
|
|
14
|
+
|
|
15
|
+
@node()
|
|
16
|
+
def finalize(self, merged):
|
|
17
|
+
return "done"
|
|
18
|
+
|
|
19
|
+
await MyFlow().run(initial_data)
|
|
20
|
+
"""
|
|
21
|
+
from typing import Any, Callable, Dict, List, Union, Optional, Literal, Set
|
|
22
|
+
import inspect
|
|
23
|
+
import asyncio
|
|
24
|
+
import sys
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
from .utils.logger import logger
|
|
29
|
+
|
|
30
|
+
# Interned constants for routing control
|
|
31
|
+
DYNAMIC = sys.intern("__dynamic__") # Runtime-determined routing
|
|
32
|
+
END = sys.intern("__end__") # Terminal state marker
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Route:
|
|
36
|
+
"""Encapsulates routing decision for DYNAMIC nodes."""
|
|
37
|
+
to: Union[Callable, str, List[Union[Callable, str]]] # Target(s): method ref, name, or list
|
|
38
|
+
data: Any = None # Payload forwarded to target(s)
|
|
39
|
+
|
|
40
|
+
def resolve(self) -> tuple:
|
|
41
|
+
"""Normalize targets to name strings.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
(str, data) for single target or (List[str], data) for parallel targets
|
|
45
|
+
"""
|
|
46
|
+
if isinstance(self.to, list):
|
|
47
|
+
names = [t.__name__ if callable(t) else t for t in self.to]
|
|
48
|
+
return names, self.data
|
|
49
|
+
if callable(self.to):
|
|
50
|
+
return self.to.__name__, self.data
|
|
51
|
+
return self.to, self.data
|
|
52
|
+
|
|
53
|
+
def goto(target: Union[Callable, str, List[Union[Callable, str]]], data: Any = None) -> Route:
|
|
54
|
+
"""Construct routing decision for DYNAMIC nodes.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
target: Single node or list of nodes (triggers parallel execution for lists)
|
|
58
|
+
data: Payload passed to target(s); duplicated for parallel targets
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Route object for flow execution engine
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
@node(goto=DYNAMIC, max_loop=3)
|
|
65
|
+
async def evaluate(self, inputs):
|
|
66
|
+
if quality_ok:
|
|
67
|
+
return goto(self.finalize, result)
|
|
68
|
+
else:
|
|
69
|
+
return goto([self.research, self.notify], feedback) # parallel dispatch
|
|
70
|
+
"""
|
|
71
|
+
return Route(to=target, data=data)
|
|
72
|
+
|
|
73
|
+
class FlowState(BaseModel):
|
|
74
|
+
"""Shared mutable state container with Pydantic validation."""
|
|
75
|
+
model_config = {"extra": "allow"} # Permits runtime field injection
|
|
76
|
+
|
|
77
|
+
class Node:
|
|
78
|
+
"""Decorator marking methods as executable flow nodes."""
|
|
79
|
+
def __init__(self,
|
|
80
|
+
start: bool = False,
|
|
81
|
+
goto: Union[str, Callable, List[Union[str, Callable]], Literal["__dynamic__"], None] = None,
|
|
82
|
+
max_loop: Optional[int] = None):
|
|
83
|
+
self.start = start
|
|
84
|
+
self.max_loop = max_loop
|
|
85
|
+
self.goto = self._normalize_goto(goto)
|
|
86
|
+
|
|
87
|
+
def _normalize_goto(self, goto: Union[str, Callable, List[Union[str, Callable]], Literal["__dynamic__"], None]) -> Union[str, List[str], Literal["__dynamic__"], None]:
|
|
88
|
+
"""Convert callable references to name strings for internal routing."""
|
|
89
|
+
if goto is None or goto is DYNAMIC:
|
|
90
|
+
return goto # type: ignore
|
|
91
|
+
elif isinstance(goto, str):
|
|
92
|
+
return goto
|
|
93
|
+
elif callable(goto):
|
|
94
|
+
return goto.__name__
|
|
95
|
+
elif isinstance(goto, list):
|
|
96
|
+
return [g.__name__ if callable(g) else g for g in goto]
|
|
97
|
+
return goto # type: ignore
|
|
98
|
+
|
|
99
|
+
def __call__(self, func):
|
|
100
|
+
func._is_flow_node = True
|
|
101
|
+
func._start_node = self.start
|
|
102
|
+
func._goto_targets = self.goto
|
|
103
|
+
func._max_loop = self.max_loop
|
|
104
|
+
return func
|
|
105
|
+
|
|
106
|
+
node = Node # Pythonic lowercase alias
|
|
107
|
+
|
|
108
|
+
class Flow:
|
|
109
|
+
"""Orchestrates DAG execution with automatic parallelization and input merging."""
|
|
110
|
+
def __init__(self,
|
|
111
|
+
initial_state: Optional[Union[Dict[str, Any], BaseModel]] = None,
|
|
112
|
+
max_steps: int = 1000,
|
|
113
|
+
validate: bool = True):
|
|
114
|
+
if isinstance(initial_state, BaseModel):
|
|
115
|
+
self.state = initial_state
|
|
116
|
+
elif isinstance(initial_state, dict):
|
|
117
|
+
self.state = FlowState(**initial_state)
|
|
118
|
+
else:
|
|
119
|
+
self.state = FlowState()
|
|
120
|
+
|
|
121
|
+
self.max_steps = max_steps # Prevent infinite loops in malformed graphs
|
|
122
|
+
self._nodes = {}
|
|
123
|
+
self._loop_counters = {} # Tracks per-node execution count
|
|
124
|
+
self._node_timings = {} # Profiling data: {node: {start, end, duration}}
|
|
125
|
+
self._flow_start_time = 0.0
|
|
126
|
+
self._flow_end_time = 0.0
|
|
127
|
+
self._collect_nodes()
|
|
128
|
+
|
|
129
|
+
if validate:
|
|
130
|
+
self._validate_graph()
|
|
131
|
+
|
|
132
|
+
def _collect_nodes(self):
|
|
133
|
+
"""Scan instance for @node-decorated methods."""
|
|
134
|
+
for name, method in inspect.getmembers(self, predicate=inspect.ismethod):
|
|
135
|
+
if hasattr(method, '_is_flow_node'):
|
|
136
|
+
self._nodes[name] = method
|
|
137
|
+
|
|
138
|
+
def _build_execution_graph(self):
|
|
139
|
+
"""Build reverse dependency map for multi-input node detection.
|
|
140
|
+
|
|
141
|
+
Returns graph where keys are target nodes and values are lists of
|
|
142
|
+
predecessor nodes pointing to them (enables input merging logic).
|
|
143
|
+
"""
|
|
144
|
+
graph = {}
|
|
145
|
+
|
|
146
|
+
for node_name, node_method in self._nodes.items():
|
|
147
|
+
goto_targets = getattr(node_method, '_goto_targets', None)
|
|
148
|
+
|
|
149
|
+
if goto_targets:
|
|
150
|
+
target_nodes = []
|
|
151
|
+
if isinstance(goto_targets, str):
|
|
152
|
+
target_nodes = [goto_targets]
|
|
153
|
+
elif isinstance(goto_targets, list):
|
|
154
|
+
target_nodes = goto_targets
|
|
155
|
+
elif isinstance(goto_targets, dict):
|
|
156
|
+
target_nodes = list(goto_targets.values())
|
|
157
|
+
|
|
158
|
+
for target in target_nodes:
|
|
159
|
+
if target not in graph:
|
|
160
|
+
graph[target] = []
|
|
161
|
+
graph[target].append(node_name)
|
|
162
|
+
|
|
163
|
+
return graph
|
|
164
|
+
|
|
165
|
+
def _validate_graph(self):
|
|
166
|
+
"""Pre-flight DAG sanity checks to catch common errors early."""
|
|
167
|
+
issues = []
|
|
168
|
+
|
|
169
|
+
start_nodes = [n for n, m in self._nodes.items()
|
|
170
|
+
if getattr(m, '_start_node', False)]
|
|
171
|
+
if len(start_nodes) == 0:
|
|
172
|
+
issues.append("❌ Error: No start node found (need @node(start=True))")
|
|
173
|
+
elif len(start_nodes) > 1:
|
|
174
|
+
issues.append(f"❌ Error: Multiple start nodes found {start_nodes}")
|
|
175
|
+
|
|
176
|
+
cycles = self._detect_cycles()
|
|
177
|
+
for cycle in cycles:
|
|
178
|
+
nodes_without_limit = [n for n in cycle
|
|
179
|
+
if not getattr(self._nodes[n], '_max_loop', None)]
|
|
180
|
+
if nodes_without_limit:
|
|
181
|
+
issues.append(f"❌ Error: Cycle detected {cycle}, "
|
|
182
|
+
f"nodes {nodes_without_limit} need max_loop")
|
|
183
|
+
|
|
184
|
+
# Skip unreachable check if DYNAMIC nodes exist (runtime-determined routing)
|
|
185
|
+
dynamic_nodes = [n for n, m in self._nodes.items()
|
|
186
|
+
if getattr(m, '_goto_targets', None) is DYNAMIC]
|
|
187
|
+
|
|
188
|
+
if start_nodes and not dynamic_nodes:
|
|
189
|
+
# Only check reachability for fully static graphs
|
|
190
|
+
reachable = self._get_reachable_nodes(start_nodes[0])
|
|
191
|
+
unreachable = set(self._nodes.keys()) - reachable
|
|
192
|
+
if unreachable:
|
|
193
|
+
issues.append(f"⚠️ Warning: Unreachable nodes {unreachable}")
|
|
194
|
+
|
|
195
|
+
# Note: max_loop is optional for DYNAMIC nodes (user's responsibility)
|
|
196
|
+
# Removed the overly strict warning since simple DYNAMIC routing is safe
|
|
197
|
+
|
|
198
|
+
if issues:
|
|
199
|
+
error_msg = "DAG validation result:\n" + "\n".join(issues)
|
|
200
|
+
errors = [i for i in issues if i.startswith("❌")]
|
|
201
|
+
if errors:
|
|
202
|
+
raise ValueError(error_msg)
|
|
203
|
+
else:
|
|
204
|
+
logger.debug(error_msg)
|
|
205
|
+
|
|
206
|
+
def _detect_cycles(self) -> List[List[str]]:
|
|
207
|
+
"""DFS-based cycle detection using recursion stack tracking."""
|
|
208
|
+
cycles = []
|
|
209
|
+
visited = set()
|
|
210
|
+
rec_stack = set()
|
|
211
|
+
|
|
212
|
+
def dfs(node: str, path: List[str]):
|
|
213
|
+
if node in rec_stack:
|
|
214
|
+
if node in path:
|
|
215
|
+
cycle_start = path.index(node)
|
|
216
|
+
cycle = path[cycle_start:]
|
|
217
|
+
if cycle not in cycles:
|
|
218
|
+
cycles.append(cycle)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if node in visited:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
visited.add(node)
|
|
225
|
+
rec_stack.add(node)
|
|
226
|
+
path.append(node)
|
|
227
|
+
|
|
228
|
+
neighbors = self._get_neighbors(node)
|
|
229
|
+
for neighbor in neighbors:
|
|
230
|
+
dfs(neighbor, path.copy())
|
|
231
|
+
|
|
232
|
+
rec_stack.remove(node)
|
|
233
|
+
|
|
234
|
+
for node_name in self._nodes:
|
|
235
|
+
if node_name not in visited:
|
|
236
|
+
dfs(node_name, [])
|
|
237
|
+
|
|
238
|
+
return cycles
|
|
239
|
+
|
|
240
|
+
def _get_neighbors(self, node_name: str) -> List[str]:
|
|
241
|
+
"""Extract static outgoing edges (excludes DYNAMIC which is runtime-determined)."""
|
|
242
|
+
node = self._nodes[node_name]
|
|
243
|
+
goto = getattr(node, '_goto_targets', None)
|
|
244
|
+
|
|
245
|
+
if not goto or goto is DYNAMIC:
|
|
246
|
+
return []
|
|
247
|
+
elif isinstance(goto, str):
|
|
248
|
+
return [goto]
|
|
249
|
+
elif isinstance(goto, list):
|
|
250
|
+
return goto
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
def _get_reachable_nodes(self, start: str) -> Set[str]:
|
|
254
|
+
"""BFS to find all nodes accessible from start (for dead code detection)."""
|
|
255
|
+
reachable = set()
|
|
256
|
+
queue = [start]
|
|
257
|
+
|
|
258
|
+
while queue:
|
|
259
|
+
current = queue.pop(0)
|
|
260
|
+
if current in reachable:
|
|
261
|
+
continue
|
|
262
|
+
reachable.add(current)
|
|
263
|
+
|
|
264
|
+
neighbors = self._get_neighbors(current)
|
|
265
|
+
queue.extend(neighbors)
|
|
266
|
+
|
|
267
|
+
return reachable
|
|
268
|
+
|
|
269
|
+
async def run(self, initial_input: Any = None):
|
|
270
|
+
"""Execute DAG with automatic parallelization and input merging.
|
|
271
|
+
|
|
272
|
+
Core execution model:
|
|
273
|
+
- Nodes with multiple predecessors receive merged dict: {pred_name: output}
|
|
274
|
+
- List targets trigger asyncio.gather for true parallel execution
|
|
275
|
+
- DYNAMIC nodes decide routing at runtime via goto() return value
|
|
276
|
+
"""
|
|
277
|
+
import time
|
|
278
|
+
|
|
279
|
+
self._flow_start_time = time.time()
|
|
280
|
+
|
|
281
|
+
logger.debug("========= Flow execution started ==========")
|
|
282
|
+
logger.debug(f"Initial input: {initial_input}")
|
|
283
|
+
|
|
284
|
+
execution_graph = self._build_execution_graph()
|
|
285
|
+
logger.debug(f"Execution graph: {execution_graph}")
|
|
286
|
+
|
|
287
|
+
start_nodes = [node for node in self._nodes.values()
|
|
288
|
+
if getattr(node, '_start_node', False)]
|
|
289
|
+
|
|
290
|
+
if not start_nodes:
|
|
291
|
+
raise ValueError("No start node found")
|
|
292
|
+
|
|
293
|
+
if len(start_nodes) > 1:
|
|
294
|
+
raise ValueError("Multiple start nodes found")
|
|
295
|
+
|
|
296
|
+
node_outputs = {}
|
|
297
|
+
pending_nodes = {}
|
|
298
|
+
|
|
299
|
+
queue = [(start_nodes[0].__name__, initial_input)]
|
|
300
|
+
executed = set()
|
|
301
|
+
step = 0
|
|
302
|
+
|
|
303
|
+
while queue:
|
|
304
|
+
step += 1
|
|
305
|
+
|
|
306
|
+
if step > self.max_steps:
|
|
307
|
+
raise RuntimeError(f"Flow exceeded max_steps limit ({self.max_steps}), possible infinite loop")
|
|
308
|
+
|
|
309
|
+
current_node_name, current_input = queue.pop(0)
|
|
310
|
+
|
|
311
|
+
if current_node_name in executed:
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
current_node = self._nodes[current_node_name]
|
|
315
|
+
|
|
316
|
+
max_loop = getattr(current_node, '_max_loop', None)
|
|
317
|
+
if max_loop:
|
|
318
|
+
self._loop_counters[current_node_name] = self._loop_counters.get(current_node_name, 0) + 1
|
|
319
|
+
if self._loop_counters[current_node_name] > max_loop:
|
|
320
|
+
raise RuntimeError(f"Node '{current_node_name}' exceeded max_loop limit ({max_loop})")
|
|
321
|
+
logger.debug(f"Loop counter: {current_node_name} [{self._loop_counters[current_node_name]}/{max_loop}]")
|
|
322
|
+
|
|
323
|
+
logger.debug(f"--- Step {step} ---")
|
|
324
|
+
logger.debug(f"Executing node: {current_node_name}")
|
|
325
|
+
logger.debug(f"Input data: {current_input}")
|
|
326
|
+
|
|
327
|
+
import time
|
|
328
|
+
if current_node_name not in self._node_timings:
|
|
329
|
+
self._node_timings[current_node_name] = {}
|
|
330
|
+
self._node_timings[current_node_name]["start"] = time.time()
|
|
331
|
+
|
|
332
|
+
result = await current_node(current_input) if asyncio.iscoroutinefunction(current_node) else current_node(current_input)
|
|
333
|
+
|
|
334
|
+
import time
|
|
335
|
+
end_time = time.time()
|
|
336
|
+
self._node_timings[current_node_name]["end"] = end_time
|
|
337
|
+
start_time = self._node_timings[current_node_name].get("start", end_time)
|
|
338
|
+
self._node_timings[current_node_name]["duration"] = end_time - start_time
|
|
339
|
+
|
|
340
|
+
logger.debug(f"Output result: {result}")
|
|
341
|
+
|
|
342
|
+
node_outputs[current_node_name] = result
|
|
343
|
+
executed.add(current_node_name)
|
|
344
|
+
|
|
345
|
+
goto_targets = getattr(current_node, '_goto_targets', None)
|
|
346
|
+
|
|
347
|
+
if not goto_targets:
|
|
348
|
+
logger.debug(f"Node {current_node_name} has no successors")
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
next_nodes = []
|
|
352
|
+
next_input = result
|
|
353
|
+
|
|
354
|
+
if goto_targets is DYNAMIC:
|
|
355
|
+
if isinstance(result, Route):
|
|
356
|
+
resolved_target, next_input = result.resolve()
|
|
357
|
+
if isinstance(resolved_target, list):
|
|
358
|
+
next_nodes = resolved_target
|
|
359
|
+
logger.debug(f"DYNAMIC routing (parallel): {next_nodes}")
|
|
360
|
+
else:
|
|
361
|
+
next_nodes = [resolved_target]
|
|
362
|
+
logger.debug(f"DYNAMIC routing: {resolved_target}")
|
|
363
|
+
elif isinstance(result, tuple) and len(result) == 2:
|
|
364
|
+
target, next_input = result
|
|
365
|
+
if callable(target):
|
|
366
|
+
next_node_name = target.__name__
|
|
367
|
+
else:
|
|
368
|
+
next_node_name = target
|
|
369
|
+
next_nodes = [next_node_name]
|
|
370
|
+
logger.debug(f"DYNAMIC routing: {next_node_name} (tuple format, recommend using goto())")
|
|
371
|
+
else:
|
|
372
|
+
raise ValueError(
|
|
373
|
+
f"DYNAMIC node '{current_node_name}' must return:\n"
|
|
374
|
+
f" - goto(target, data) [recommended]\n"
|
|
375
|
+
f" - (target, data) [legacy format]"
|
|
376
|
+
)
|
|
377
|
+
elif isinstance(goto_targets, list):
|
|
378
|
+
next_nodes = goto_targets
|
|
379
|
+
logger.debug(f"Parallel execution: {next_nodes}")
|
|
380
|
+
elif isinstance(goto_targets, str):
|
|
381
|
+
next_nodes = [goto_targets]
|
|
382
|
+
logger.debug(f"Next node: {goto_targets}")
|
|
383
|
+
else:
|
|
384
|
+
raise ValueError(f"Unsupported goto type: {type(goto_targets)}")
|
|
385
|
+
|
|
386
|
+
if len(next_nodes) > 1:
|
|
387
|
+
logger.debug(f"Using asyncio.gather to execute {len(next_nodes)} nodes in parallel")
|
|
388
|
+
|
|
389
|
+
import time
|
|
390
|
+
|
|
391
|
+
async def execute_with_timing(node_name, node_func, node_input):
|
|
392
|
+
"""Wrapper to capture execution metrics for parallel nodes."""
|
|
393
|
+
if node_name not in self._node_timings:
|
|
394
|
+
self._node_timings[node_name] = {}
|
|
395
|
+
self._node_timings[node_name]["start"] = time.time()
|
|
396
|
+
|
|
397
|
+
if asyncio.iscoroutinefunction(node_func):
|
|
398
|
+
result = await node_func(node_input)
|
|
399
|
+
else:
|
|
400
|
+
result = node_func(node_input)
|
|
401
|
+
|
|
402
|
+
end_time = time.time()
|
|
403
|
+
self._node_timings[node_name]["end"] = end_time
|
|
404
|
+
start_time = self._node_timings[node_name].get("start", end_time)
|
|
405
|
+
self._node_timings[node_name]["duration"] = end_time - start_time
|
|
406
|
+
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
tasks = []
|
|
410
|
+
for next_node_name in next_nodes:
|
|
411
|
+
next_node = self._nodes[next_node_name]
|
|
412
|
+
tasks.append(execute_with_timing(next_node_name, next_node, next_input))
|
|
413
|
+
|
|
414
|
+
results = await asyncio.gather(*tasks)
|
|
415
|
+
|
|
416
|
+
for node_name, node_result in zip(next_nodes, results):
|
|
417
|
+
logger.debug(f" - {node_name} completed: {node_result}")
|
|
418
|
+
node_outputs[node_name] = node_result
|
|
419
|
+
executed.add(node_name)
|
|
420
|
+
|
|
421
|
+
node_method = self._nodes[node_name]
|
|
422
|
+
node_goto = getattr(node_method, '_goto_targets', None)
|
|
423
|
+
|
|
424
|
+
if node_goto:
|
|
425
|
+
if node_goto is DYNAMIC:
|
|
426
|
+
subsequent_nodes = []
|
|
427
|
+
elif isinstance(node_goto, str):
|
|
428
|
+
subsequent_nodes = [node_goto]
|
|
429
|
+
elif isinstance(node_goto, list):
|
|
430
|
+
subsequent_nodes = node_goto
|
|
431
|
+
else:
|
|
432
|
+
subsequent_nodes = []
|
|
433
|
+
|
|
434
|
+
for sub_node in subsequent_nodes:
|
|
435
|
+
if sub_node in execution_graph:
|
|
436
|
+
predecessors = execution_graph[sub_node]
|
|
437
|
+
if all(pred in executed for pred in predecessors):
|
|
438
|
+
merged_input = {pred: node_outputs[pred] for pred in predecessors}
|
|
439
|
+
logger.debug(f"Node {sub_node}: all predecessors completed, merging inputs: {list(merged_input.keys())}")
|
|
440
|
+
queue.append((sub_node, merged_input))
|
|
441
|
+
else:
|
|
442
|
+
queue.append((sub_node, node_result))
|
|
443
|
+
else:
|
|
444
|
+
for next_node_name in next_nodes:
|
|
445
|
+
if next_node_name in execution_graph:
|
|
446
|
+
predecessors = execution_graph[next_node_name]
|
|
447
|
+
if all(pred in executed for pred in predecessors):
|
|
448
|
+
if len(predecessors) > 1:
|
|
449
|
+
merged_input = {pred: node_outputs[pred] for pred in predecessors}
|
|
450
|
+
logger.debug(f"Node {next_node_name}: all predecessors completed, merging inputs: {list(merged_input.keys())}")
|
|
451
|
+
queue.append((next_node_name, merged_input))
|
|
452
|
+
else:
|
|
453
|
+
queue.append((next_node_name, next_input))
|
|
454
|
+
else:
|
|
455
|
+
waiting = [p for p in predecessors if p not in executed]
|
|
456
|
+
logger.debug(f"Node {next_node_name} waiting for predecessors: {waiting}")
|
|
457
|
+
else:
|
|
458
|
+
queue.append((next_node_name, next_input))
|
|
459
|
+
|
|
460
|
+
import time
|
|
461
|
+
self._flow_end_time = time.time()
|
|
462
|
+
|
|
463
|
+
logger.debug("========== Flow execution completed ==========")
|
|
464
|
+
logger.debug(f"Final state: {self.state}")
|
|
465
|
+
return self.state
|
|
466
|
+
|
|
467
|
+
def stats(self) -> dict:
|
|
468
|
+
"""Extract profiling metrics collected during flow execution.
|
|
469
|
+
|
|
470
|
+
Returns dict with timing breakdown by node and total flow duration.
|
|
471
|
+
Useful for identifying bottlenecks in complex workflows.
|
|
472
|
+
"""
|
|
473
|
+
total_duration = self._flow_end_time - self._flow_start_time if self._flow_end_time > 0 else 0
|
|
474
|
+
|
|
475
|
+
node_stats = {}
|
|
476
|
+
for node_name, timing in self._node_timings.items():
|
|
477
|
+
duration = timing.get("duration", 0)
|
|
478
|
+
percentage = (duration / total_duration * 100) if total_duration > 0 else 0
|
|
479
|
+
node_stats[node_name] = {
|
|
480
|
+
"duration": round(duration, 2),
|
|
481
|
+
"percentage": round(percentage, 2)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
"timing": {
|
|
486
|
+
"total_duration": round(total_duration, 2),
|
|
487
|
+
"nodes": node_stats
|
|
488
|
+
}
|
|
489
|
+
}
|
rill/utils/__init__.py
ADDED
|
File without changes
|
rill/utils/logger.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""统一的日志模块
|
|
2
|
+
|
|
3
|
+
基于 loguru 的简单易用的日志系统,可以在任何地方直接 import 使用。
|
|
4
|
+
|
|
5
|
+
使用方式:
|
|
6
|
+
from rill.utils.logger import logger
|
|
7
|
+
|
|
8
|
+
logger.info("这是一条信息")
|
|
9
|
+
logger.debug("调试信息")
|
|
10
|
+
logger.warning("警告信息")
|
|
11
|
+
logger.error("错误信息")
|
|
12
|
+
|
|
13
|
+
配置方式:
|
|
14
|
+
通过环境变量配置:
|
|
15
|
+
- LOG_LEVEL: 日志级别(DEBUG/INFO/WARNING/ERROR)默认 INFO
|
|
16
|
+
- LOG_TO_FILE: 是否输出到文件(true/false)默认 false
|
|
17
|
+
- LOG_FILE: 日志文件路径,默认 logs/rill.log
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from loguru import logger as _logger
|
|
25
|
+
|
|
26
|
+
# 移除 loguru 的默认 handler
|
|
27
|
+
_logger.remove()
|
|
28
|
+
|
|
29
|
+
# ===== 配置参数(从环境变量读取) =====
|
|
30
|
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
31
|
+
LOG_TO_FILE = os.getenv("LOG_TO_FILE", "false").lower() == "true"
|
|
32
|
+
LOG_FILE = os.getenv("LOG_FILE", "logs/rill.log")
|
|
33
|
+
|
|
34
|
+
# ===== 日志格式 =====
|
|
35
|
+
# 简洁格式:时间 | 级别 | 消息
|
|
36
|
+
SIMPLE_FORMAT = (
|
|
37
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
|
38
|
+
"<level>{level: <8}</level> | "
|
|
39
|
+
"<level>{message}</level>"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# 详细格式:时间 | 级别 | 位置 | 消息
|
|
43
|
+
DETAILED_FORMAT = (
|
|
44
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
|
45
|
+
"<level>{level: <8}</level> | "
|
|
46
|
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
|
47
|
+
"<level>{message}</level>"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# 根据日志级别选择格式(DEBUG 使用详细格式,其他使用简洁格式)
|
|
51
|
+
LOG_FORMAT = DETAILED_FORMAT if LOG_LEVEL == "DEBUG" else SIMPLE_FORMAT
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ===== 配置 logger =====
|
|
55
|
+
_logger_initialized = False
|
|
56
|
+
|
|
57
|
+
def _setup_logger():
|
|
58
|
+
"""配置 logger(延迟初始化,第一次使用时自动执行)"""
|
|
59
|
+
global _logger_initialized
|
|
60
|
+
if _logger_initialized:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# 读取环境变量
|
|
64
|
+
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
65
|
+
log_to_file = os.getenv("LOG_TO_FILE", "false").lower() == "true"
|
|
66
|
+
log_file = os.getenv("LOG_FILE", "logs/rill.log")
|
|
67
|
+
log_format = DETAILED_FORMAT if log_level == "DEBUG" else SIMPLE_FORMAT
|
|
68
|
+
|
|
69
|
+
# 1. 控制台输出(始终开启)
|
|
70
|
+
_logger.add(
|
|
71
|
+
sys.stdout,
|
|
72
|
+
format=log_format,
|
|
73
|
+
level=log_level,
|
|
74
|
+
colorize=True,
|
|
75
|
+
backtrace=True,
|
|
76
|
+
diagnose=True
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 2. 文件输出(可选)
|
|
80
|
+
if log_to_file:
|
|
81
|
+
# 确保日志目录存在
|
|
82
|
+
log_file_path = Path(log_file)
|
|
83
|
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
_logger.add(
|
|
86
|
+
log_file,
|
|
87
|
+
format=DETAILED_FORMAT, # 文件始终使用详细格式
|
|
88
|
+
level=log_level,
|
|
89
|
+
rotation="10 MB", # 单文件最大 10MB
|
|
90
|
+
retention="30 days", # 保留 30 天
|
|
91
|
+
compression="zip", # 压缩旧日志
|
|
92
|
+
backtrace=True,
|
|
93
|
+
diagnose=True,
|
|
94
|
+
encoding="utf-8"
|
|
95
|
+
)
|
|
96
|
+
_logger.info(f"日志文件输出已启用: {log_file}")
|
|
97
|
+
|
|
98
|
+
_logger_initialized = True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# 包装 logger,实现延迟初始化
|
|
102
|
+
class _LazyLogger:
|
|
103
|
+
"""延迟初始化的 logger 包装器"""
|
|
104
|
+
|
|
105
|
+
def __getattr__(self, name):
|
|
106
|
+
# 第一次访问任何方法时,先初始化 logger
|
|
107
|
+
_setup_logger()
|
|
108
|
+
# 然后返回真正的 logger 的属性
|
|
109
|
+
return getattr(_logger, name)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# 导出 logger(用户直接使用这个)
|
|
113
|
+
logger = _LazyLogger()
|
|
114
|
+
|
|
115
|
+
def set_level(level: str):
|
|
116
|
+
"""运行时动态设置日志级别
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
level: 日志级别 (DEBUG/INFO/WARNING/ERROR)
|
|
120
|
+
"""
|
|
121
|
+
level = level.upper()
|
|
122
|
+
_logger.remove() # 移除所有 handler
|
|
123
|
+
# 重新添加控制台输出
|
|
124
|
+
_logger.add(
|
|
125
|
+
sys.stdout,
|
|
126
|
+
format=DETAILED_FORMAT if level == "DEBUG" else SIMPLE_FORMAT,
|
|
127
|
+
level=level,
|
|
128
|
+
colorize=True,
|
|
129
|
+
backtrace=True,
|
|
130
|
+
diagnose=True
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
__all__ = ["logger", "set_level"]
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rillpy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Rill - AI Agent Framework
|
|
5
|
+
Author: zhixiangxue
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/zhixiangxue/rill-ai
|
|
8
|
+
Project-URL: Repository, https://github.com/zhixiangxue/rill-ai
|
|
9
|
+
Project-URL: Issues, https://github.com/zhixiangxue/rill-ai/issues
|
|
10
|
+
Keywords: ai,agent,framework,workflow
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pydantic>=2.0.0
|
|
21
|
+
Requires-Dist: loguru>=0.7.0
|
|
22
|
+
Requires-Dist: chakpy>=0.1.4
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
<div align="center">
|
|
26
|
+
|
|
27
|
+
<a href="https://github.com/zhixiangxue/rill-ai"><img src="https://raw.githubusercontent.com/zhixiangxue/rill-ai/main/docs/assets/logo.png" alt="Rill Logo" width="120"></a>
|
|
28
|
+
|
|
29
|
+
[](https://badge.fury.io/py/rillpy)
|
|
30
|
+
[](https://pypi.org/project/rillpy/)
|
|
31
|
+
[](https://github.com/zhixiangxue/rill-ai/blob/main/LICENSE)
|
|
32
|
+
[](https://pypi.org/project/rillpy/)
|
|
33
|
+
[](https://github.com/zhixiangxue/rill-ai)
|
|
34
|
+
|
|
35
|
+
**A zero-dependency flow orchestration kernel for building AI workflows your way.**
|
|
36
|
+
|
|
37
|
+
**A minimal orchestration layer that lets you use any LLM client, any tools, any storage to build your AI agent applications.**
|
|
38
|
+
|
|
39
|
+
Inspired by CrewAI and LangGraph, designed to be lighter and simpler.
|
|
40
|
+
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Why Rill?
|
|
46
|
+
|
|
47
|
+
Building AI agents shouldn't require heavy frameworks. Sometimes you just need a simple orchestration piece:
|
|
48
|
+
|
|
49
|
+
- Want to use your own LLM client (chak / OpenAI SDK / Anthropic SDK)? ✅
|
|
50
|
+
- Want to use your own tools (functions / MCP servers / custom implementations)? ✅
|
|
51
|
+
- Want to keep your codebase lightweight and dependencies minimal? ✅
|
|
52
|
+
- Prefer code over YAML/JSON configs? Code is the orchestration. ✅
|
|
53
|
+
|
|
54
|
+
**Rill is just an orchestration component** - bring your own pieces, Rill handles the flow.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Core Philosophy
|
|
59
|
+
|
|
60
|
+
Rill embraces the **"code is flow"** design philosophy pioneered by CrewAI - use decorators to define nodes, use Python functions to express logic, no YAML or JSON configs needed.
|
|
61
|
+
|
|
62
|
+
Built on this foundation, Rill adds:
|
|
63
|
+
|
|
64
|
+
1. **Forward routing**: Declare next steps with `goto` right where you are, not reverse subscription
|
|
65
|
+
2. **Zero binding**: Framework handles orchestration only, everything else is your call
|
|
66
|
+
|
|
67
|
+
*Special thanks to CrewAI and LangGraph for inspiring Rill's design.*
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
### Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# From PyPI (coming soon)
|
|
77
|
+
pip install rillpy
|
|
78
|
+
|
|
79
|
+
# From GitHub
|
|
80
|
+
pip install git+https://github.com/zhixiangxue/rill-ai.git@main
|
|
81
|
+
|
|
82
|
+
# Local development
|
|
83
|
+
git clone https://github.com/zhixiangxue/rill-ai.git
|
|
84
|
+
cd rill-ai
|
|
85
|
+
pip install -e .
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Build a RAG workflow in 30 seconds
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from rill import Flow, node, DYNAMIC, goto
|
|
92
|
+
|
|
93
|
+
class MyRAGFlow(Flow):
|
|
94
|
+
@node(start=True, goto=DYNAMIC)
|
|
95
|
+
async def query(self, user_input):
|
|
96
|
+
# Use your own LLM client (e.g., chak)
|
|
97
|
+
from chak import Conversation
|
|
98
|
+
conv = Conversation("openai/gpt-4o-mini", api_key="YOUR_KEY")
|
|
99
|
+
result = await conv.asend(user_input)
|
|
100
|
+
|
|
101
|
+
# Decide routing based on LLM response
|
|
102
|
+
if "search" in result.content.lower():
|
|
103
|
+
# List means parallel: trigger vector search and web search simultaneously
|
|
104
|
+
return goto([self.vector_search, self.web_search], user_input)
|
|
105
|
+
return goto(self.answer, result.content)
|
|
106
|
+
|
|
107
|
+
@node(goto="answer")
|
|
108
|
+
async def vector_search(self, query):
|
|
109
|
+
# Use your favorite vector database
|
|
110
|
+
return await my_chromadb.search(query)
|
|
111
|
+
|
|
112
|
+
@node(goto="answer")
|
|
113
|
+
async def web_search(self, query):
|
|
114
|
+
# Use your own search tool
|
|
115
|
+
return await my_searxng.search(query)
|
|
116
|
+
|
|
117
|
+
@node()
|
|
118
|
+
async def answer(self, sources):
|
|
119
|
+
# Multiple predecessors auto-merge: sources = {"vector_search": [...], "web_search": [...]}
|
|
120
|
+
from chak import Conversation
|
|
121
|
+
conv = Conversation("openai/gpt-4o-mini", api_key="YOUR_KEY")
|
|
122
|
+
return await conv.asend(str(sources))
|
|
123
|
+
|
|
124
|
+
# Run
|
|
125
|
+
await MyRAGFlow().run("What is quantum entanglement?")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Notice:**
|
|
129
|
+
- ✅ LLM client: `chak` (or OpenAI SDK / Anthropic SDK, your choice)
|
|
130
|
+
- ✅ Vector database: `chromadb` (or Pinecone / Qdrant, your choice)
|
|
131
|
+
- ✅ Search tool: your own implementation (or MCP / LangChain Tools, your choice)
|
|
132
|
+
- ✅ Rill only handles: which node first, which ones parallel, how to merge inputs
|
|
133
|
+
- ✅ Data flows through return values (node → node) and shared state (`self.state`)
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Core Features
|
|
138
|
+
|
|
139
|
+
### 🌱 Zero Binding
|
|
140
|
+
|
|
141
|
+
Framework doesn't care what LLM, tools, or databases you use. Only provides orchestration.
|
|
142
|
+
|
|
143
|
+
### 🪴 Code is Flow
|
|
144
|
+
|
|
145
|
+
Define nodes with `@node` decorator, declare routing with `goto`, no DSL needed.
|
|
146
|
+
|
|
147
|
+
### 🌻 Forward Declaration
|
|
148
|
+
|
|
149
|
+
Use `goto(next, data)` to directly specify next step in current node. Matches how humans think.
|
|
150
|
+
|
|
151
|
+
### 🌾 List Means Parallel
|
|
152
|
+
|
|
153
|
+
`goto([A, B], data)` automatically triggers `asyncio.gather` for parallel execution.
|
|
154
|
+
|
|
155
|
+
### 🌿 Auto Input Merge
|
|
156
|
+
|
|
157
|
+
When multiple predecessors point to same node, framework auto-merges outputs as `{pred_name: output}` dict.
|
|
158
|
+
|
|
159
|
+
### 🍀 Safety Guards
|
|
160
|
+
|
|
161
|
+
Graph validation (start point / cycles / reachability) + `max_loop` to prevent infinite loops.
|
|
162
|
+
|
|
163
|
+
### 🌲 Observable
|
|
164
|
+
|
|
165
|
+
`Flow.stats()` tracks node execution time, `logger` traces execution flow.
|
|
166
|
+
|
|
167
|
+
### 🪴 Shared State Management
|
|
168
|
+
|
|
169
|
+
Rill provides `FlowState` for sharing data across nodes. Simpler than LangGraph's in-node state updates.
|
|
170
|
+
|
|
171
|
+
**TODO**: Parallel nodes updating state simultaneously may have thread-safety issues. Community contributions welcome.
|
|
172
|
+
|
|
173
|
+
### 🌾 Return Value as Input
|
|
174
|
+
|
|
175
|
+
Predecessor node's return value becomes successor node's input parameter. No need to put everything in state.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Common Patterns
|
|
180
|
+
|
|
181
|
+
### Conditional Branching + Parallel Execution
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
class ResearchFlow(Flow):
|
|
185
|
+
@node(start=True, goto=DYNAMIC)
|
|
186
|
+
async def decide(self, topic):
|
|
187
|
+
complexity = await self.analyze_complexity(topic)
|
|
188
|
+
|
|
189
|
+
if complexity > 0.8:
|
|
190
|
+
# High complexity: parallel deep research
|
|
191
|
+
return goto([self.academic_search, self.expert_interview], topic)
|
|
192
|
+
else:
|
|
193
|
+
# Low complexity: quick search
|
|
194
|
+
return goto(self.web_search, topic)
|
|
195
|
+
|
|
196
|
+
@node(goto="synthesize")
|
|
197
|
+
async def academic_search(self, topic):
|
|
198
|
+
return await search_papers(topic)
|
|
199
|
+
|
|
200
|
+
@node(goto="synthesize")
|
|
201
|
+
async def expert_interview(self, topic):
|
|
202
|
+
return await interview_experts(topic)
|
|
203
|
+
|
|
204
|
+
@node(goto="synthesize")
|
|
205
|
+
async def web_search(self, topic):
|
|
206
|
+
return await search_web(topic)
|
|
207
|
+
|
|
208
|
+
@node()
|
|
209
|
+
async def synthesize(self, sources):
|
|
210
|
+
# Auto-merge: sources could be {"academic_search": ..., "expert_interview": ...}
|
|
211
|
+
# or just web_search output (single predecessor)
|
|
212
|
+
return await generate_report(sources)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Loop + Exit Condition
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
class IterativeFlow(Flow):
|
|
219
|
+
@node(start=True, goto=DYNAMIC, max_loop=5)
|
|
220
|
+
async def generate(self, prompt):
|
|
221
|
+
result = await llm_generate(prompt)
|
|
222
|
+
quality = await self.evaluate(result)
|
|
223
|
+
|
|
224
|
+
if quality > 0.9:
|
|
225
|
+
return goto(self.finalize, result)
|
|
226
|
+
else:
|
|
227
|
+
# Loop back with feedback
|
|
228
|
+
return goto(self.generate, {"prompt": prompt, "feedback": quality})
|
|
229
|
+
|
|
230
|
+
@node()
|
|
231
|
+
async def finalize(self, result):
|
|
232
|
+
return result
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Using Shared State
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
class MyWorkflow(Flow):
|
|
239
|
+
@node(start=True, goto=["fetch_data", "process_config"])
|
|
240
|
+
async def begin(self, inputs):
|
|
241
|
+
# Store inputs in state for other nodes to access
|
|
242
|
+
self.state.user_id = inputs["user_id"]
|
|
243
|
+
self.state.query = inputs["query"]
|
|
244
|
+
self.state.results = [] # Initialize shared collection
|
|
245
|
+
|
|
246
|
+
@node(goto="merge")
|
|
247
|
+
async def fetch_data(self, previous_result):
|
|
248
|
+
# Access state from parallel node
|
|
249
|
+
data = await api_call(self.state.user_id, self.state.query)
|
|
250
|
+
|
|
251
|
+
# Accumulate results in state
|
|
252
|
+
self.state.results.append({"source": "api", "data": data})
|
|
253
|
+
return data
|
|
254
|
+
|
|
255
|
+
@node(goto="merge")
|
|
256
|
+
async def process_config(self, previous_result):
|
|
257
|
+
# Another parallel node accessing same state
|
|
258
|
+
config = load_config(self.state.user_id)
|
|
259
|
+
|
|
260
|
+
# Also update shared state
|
|
261
|
+
self.state.config = config
|
|
262
|
+
return config
|
|
263
|
+
|
|
264
|
+
@node()
|
|
265
|
+
async def merge(self, inputs):
|
|
266
|
+
# inputs = {"fetch_data": ..., "process_config": ...}
|
|
267
|
+
# state contains accumulated data from all nodes
|
|
268
|
+
final_result = combine(
|
|
269
|
+
inputs["fetch_data"],
|
|
270
|
+
inputs["process_config"],
|
|
271
|
+
self.state.results # Access shared state
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
self.state.final_output = final_result
|
|
275
|
+
return final_result
|
|
276
|
+
|
|
277
|
+
# Run the workflow
|
|
278
|
+
flow = MyWorkflow()
|
|
279
|
+
final_state = await flow.run({
|
|
280
|
+
"user_id": 123,
|
|
281
|
+
"query": "hello"
|
|
282
|
+
}) # 🎯 Rill auto-converts your dict to a Pydantic FlowState object!
|
|
283
|
+
|
|
284
|
+
# Access final state (Pydantic model)
|
|
285
|
+
print(final_state.final_output) # 🎉 Flow.run() returns the final FlowState
|
|
286
|
+
print(final_state.user_id) # Access any field stored during execution
|
|
287
|
+
print(final_state.results) # All accumulated data persists here
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**State vs Return Value:**
|
|
291
|
+
- **Return value**: Direct data passing from predecessor to successor (the main data pipeline)
|
|
292
|
+
- **State**: Shared context accessible from any node (for metadata, counters, cross-branch data)
|
|
293
|
+
- **Key difference**: Return values flow through edges, state persists across the entire workflow
|
|
294
|
+
- Use return values for primary data flow, use state for auxiliary data that multiple nodes need
|
|
295
|
+
|
|
296
|
+
**Known Issue:**
|
|
297
|
+
- ⚠️ Parallel nodes updating state simultaneously may cause race conditions
|
|
298
|
+
- 🔧 TODO: Need thread-safe state update mechanism (community contributions welcome)
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## When to Use Rill?
|
|
303
|
+
|
|
304
|
+
| Your Situation | Recommendation |
|
|
305
|
+
|----------------|----------------|
|
|
306
|
+
| Quick GPT app, don't want to manage anything | 👉 LangChain / LangGraph (all-in-one convenience) |
|
|
307
|
+
| Want to use my own LLM client (chak / OpenAI SDK) + custom tools | 👉 **Rill** (orchestration freedom) |
|
|
308
|
+
| Just want pure orchestration layer, pick other components myself | 👉 **Rill** |
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## FAQ
|
|
313
|
+
|
|
314
|
+
**Q: What's the difference between Rill and LangGraph?**
|
|
315
|
+
A: LangGraph is an all-in-one suite (orchestration + LLM + tools + memory), Rill only handles orchestration layer, other components are your choice.
|
|
316
|
+
|
|
317
|
+
**Q: I'm already using LangChain Tools, can I use Rill?**
|
|
318
|
+
A: Yes! Rill doesn't care where your tools come from, just call them directly in nodes.
|
|
319
|
+
|
|
320
|
+
**Q: Does Rill support state persistence?**
|
|
321
|
+
A: Current `FlowState` is in-memory state (Pydantic model), persistence is your choice (Redis / PostgreSQL / files), no binding to any storage solution.
|
|
322
|
+
|
|
323
|
+
**Q: I want to use my own LLM client (e.g., chak), how to integrate?**
|
|
324
|
+
A: Just `import chak` in nodes and call it, Rill doesn't care which LLM you use. Example:
|
|
325
|
+
```python
|
|
326
|
+
@node(start=True, goto="process")
|
|
327
|
+
async def query(self, user_input):
|
|
328
|
+
from chak import Conversation # Your LLM client
|
|
329
|
+
conv = Conversation("openai/gpt-4o-mini", api_key="YOUR_KEY")
|
|
330
|
+
return await conv.asend(user_input)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
**Q: When do I need `max_loop`?**
|
|
334
|
+
A: When your flow has cycles (e.g., "generate → evaluate → regenerate"), use `max_loop` to limit loop iterations and prevent infinite loops.
|
|
335
|
+
|
|
336
|
+
**Q: How does input merging work?**
|
|
337
|
+
A: When multiple predecessor nodes point to the same target node, and all predecessors complete, the framework merges their outputs as a dict `{pred_name: output}` and passes it to the target node.
|
|
338
|
+
|
|
339
|
+
**Q: What's the difference between state and return value?**
|
|
340
|
+
A: They serve different purposes:
|
|
341
|
+
- **Node return value**: Passes data to the next node(s) through the flow edge. This is the main data pipeline.
|
|
342
|
+
- **State (`self.state`)**: A shared Pydantic object accessible from all nodes throughout the workflow. Use it for metadata, counters, configuration, or data that multiple branches need to access.
|
|
343
|
+
- **Example**: Return the processed result to next node, but store statistics/metadata in state.
|
|
344
|
+
|
|
345
|
+
**Q: Is state update thread-safe in parallel nodes?**
|
|
346
|
+
A: Not yet. Parallel nodes updating state simultaneously may cause race conditions. This is a known TODO. For now, avoid state updates in parallel nodes or use return values instead.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## API Reference
|
|
351
|
+
|
|
352
|
+
### `Flow`
|
|
353
|
+
|
|
354
|
+
Orchestration engine, inherit to define your workflow.
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
class MyFlow(Flow):
|
|
358
|
+
def __init__(self, initial_state=None, max_steps=1000, validate=True):
|
|
359
|
+
super().__init__(initial_state, max_steps, validate)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
- `initial_state`: Initial state dict or Pydantic model
|
|
363
|
+
- `max_steps`: Max execution steps (prevent infinite loops)
|
|
364
|
+
- `validate`: Whether to validate graph before execution
|
|
365
|
+
|
|
366
|
+
### `@node`
|
|
367
|
+
|
|
368
|
+
Decorator to mark methods as executable flow nodes.
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
@node(start=False, goto=None, max_loop=None)
|
|
372
|
+
def my_node(self, inputs):
|
|
373
|
+
pass
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
- `start`: Whether this is the start node
|
|
377
|
+
- `goto`: Next node(s), can be:
|
|
378
|
+
- `None`: No successors (end node)
|
|
379
|
+
- `"node_name"`: Single next node
|
|
380
|
+
- `["node1", "node2"]`: Multiple nodes (parallel execution)
|
|
381
|
+
- `DYNAMIC`: Runtime-determined routing (must return `goto(...)` in node)
|
|
382
|
+
- `max_loop`: Max loop count for this node (for cycle detection)
|
|
383
|
+
|
|
384
|
+
### `goto(target, data)`
|
|
385
|
+
|
|
386
|
+
Construct routing decision for DYNAMIC nodes.
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
@node(goto=DYNAMIC)
|
|
390
|
+
async def decide(self, inputs):
|
|
391
|
+
if condition:
|
|
392
|
+
return goto(self.next_node, data)
|
|
393
|
+
else:
|
|
394
|
+
return goto([self.task_a, self.task_b], data) # Parallel
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
- `target`: Single node or list of nodes (list triggers parallel execution)
|
|
398
|
+
- `data`: Payload passed to target node(s)
|
|
399
|
+
|
|
400
|
+
### `DYNAMIC`
|
|
401
|
+
|
|
402
|
+
Constant for runtime-determined routing. Use with `goto()`.
|
|
403
|
+
|
|
404
|
+
### `FlowState`
|
|
405
|
+
|
|
406
|
+
Shared mutable state container (Pydantic model).
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
# Access state from any node
|
|
410
|
+
self.state.custom_field = "value" # Runtime field injection
|
|
411
|
+
self.state.user_id = 123
|
|
412
|
+
self.state.results = [] # Shared collection
|
|
413
|
+
|
|
414
|
+
# Two independent data channels:
|
|
415
|
+
# 1. Return value: flows through edges (node → successor)
|
|
416
|
+
# 2. State: shared context persists across entire workflow
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**Known Issue**: Parallel nodes updating state simultaneously may cause race conditions (TODO).
|
|
420
|
+
|
|
421
|
+
### `Flow.run(initial_input)`
|
|
422
|
+
|
|
423
|
+
Execute the flow.
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
result_state = await flow.run({"user_input": "Hello"})
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### `Flow.stats()`
|
|
430
|
+
|
|
431
|
+
Get execution statistics.
|
|
432
|
+
|
|
433
|
+
```python
|
|
434
|
+
stats = flow.stats()
|
|
435
|
+
# {
|
|
436
|
+
# "timing": {
|
|
437
|
+
# "total_duration": 2.35,
|
|
438
|
+
# "nodes": {
|
|
439
|
+
# "query": {"duration": 1.2, "percentage": 51.06},
|
|
440
|
+
# "search": {"duration": 0.8, "percentage": 34.04}
|
|
441
|
+
# }
|
|
442
|
+
# }
|
|
443
|
+
# }
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Architecture
|
|
449
|
+
|
|
450
|
+
```
|
|
451
|
+
┌─────────────────────────────────────────┐
|
|
452
|
+
│ Your Application Layer │
|
|
453
|
+
│ LLM: chak / OpenAI / Anthropic / ... │
|
|
454
|
+
│ Tools: MCP / Functions / LangChain │
|
|
455
|
+
│ Storage: ChromaDB / PostgreSQL / Redis │
|
|
456
|
+
└──────────────┬──────────────────────────┘
|
|
457
|
+
│ Only depends on Rill for orchestration
|
|
458
|
+
┌──────────────▼──────────────────────────┐
|
|
459
|
+
│ Rill Orchestration Layer │
|
|
460
|
+
│ @node + goto + parallel + State │
|
|
461
|
+
└─────────────────────────────────────────┘
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## Dependencies
|
|
467
|
+
|
|
468
|
+
- Python >= 3.8
|
|
469
|
+
- pydantic >= 2.0.0
|
|
470
|
+
- loguru >= 0.7.0
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## License
|
|
475
|
+
|
|
476
|
+
MIT License - see LICENSE file for details.
|
|
477
|
+
|
|
478
|
+
<div align="right"><a href="https://github.com/zhixiangxue/rill-ai"><img src="https://raw.githubusercontent.com/zhixiangxue/rill-ai/main/docs/assets/logo.png" alt="Demo Video" width="120"></a></div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
rill/__init__.py,sha256=FiHnnChtBeXkCupRcRETBESSpF1AYuSX0PTN65DMUc8,54
|
|
2
|
+
rill/flow.py,sha256=48peKx_6q-ZHGCRaXgR1lebw9t_Lzx6luXyRjuq2-DE,20551
|
|
3
|
+
rill/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
rill/utils/logger.py,sha256=rYLXRoZeTnw6PAd0HhstsPQhyJ1Swezhj_UXxMmZfmE,3873
|
|
5
|
+
rillpy-0.1.0.dist-info/licenses/LICENSE,sha256=keVEnzyfJEBdd59gWSskOiqqKiqqKzRKjZqtdLpUK1s,1068
|
|
6
|
+
rillpy-0.1.0.dist-info/METADATA,sha256=NaUwI8uM9hl7IkCvUhF-As1FLYfqivLn-3EzDLBCWpM,16449
|
|
7
|
+
rillpy-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
rillpy-0.1.0.dist-info/top_level.txt,sha256=5sx8p7zijGPad64hI4HjSCZv4cs4T5bivv4ysPjfALw,5
|
|
9
|
+
rillpy-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 zhixiangxue
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rill
|