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 ADDED
@@ -0,0 +1,3 @@
1
+ """Rill - AI Agent Framework"""
2
+
3
+ __version__ = "0.1.0"
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
+ [![PyPI version](https://badge.fury.io/py/rillpy.svg)](https://badge.fury.io/py/rillpy)
30
+ [![Python Version](https://img.shields.io/pypi/pyversions/rillpy)](https://pypi.org/project/rillpy/)
31
+ [![License](https://img.shields.io/github/license/zhixiangxue/rill-ai)](https://github.com/zhixiangxue/rill-ai/blob/main/LICENSE)
32
+ [![Downloads](https://img.shields.io/pypi/dm/rillpy)](https://pypi.org/project/rillpy/)
33
+ [![GitHub Stars](https://img.shields.io/github/stars/zhixiangxue/rill-ai?style=social)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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