agstack 1.14.0__tar.gz → 1.16.0__tar.gz
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.
- {agstack-1.14.0 → agstack-1.16.0}/PKG-INFO +1 -1
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/flow.py +163 -101
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/loader.py +1 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/agent_node.py +18 -8
- {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-1.14.0 → agstack-1.16.0}/pyproject.toml +1 -1
- {agstack-1.14.0 → agstack-1.16.0}/tests/test_flow_io.py +213 -37
- {agstack-1.14.0 → agstack-1.16.0}/tests/test_flow_switch_subflow.py +4 -4
- {agstack-1.14.0 → agstack-1.16.0}/LICENSE +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/README.md +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/config/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/config/logger.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/config/manager.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/config/types.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/contexts.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/decorators.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/events.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/exceptions.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/exception.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/middleware.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/offline.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/sse.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/infra/db/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/infra/es/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/infra/kg/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/infra/mq/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/client.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/agent.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/context.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/event.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/factory.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/base.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/records.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/registry.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/state.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/tool.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/prompts.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/token.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/schema.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/security/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/security/casbin.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/security/crypt.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack/status.py +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/SOURCES.txt +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/requires.txt +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/top_level.txt +0 -0
- {agstack-1.14.0 → agstack-1.16.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.16.0
|
|
4
4
|
Summary: Production-ready toolkit for building FastAPI and LLM applications
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -16,6 +16,28 @@ if TYPE_CHECKING:
|
|
|
16
16
|
from .nodes.base import NodeHandler
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
_OPERATORS = (">=", "<=", "!=", "==", ">", "<")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_literal(s: str) -> Any:
|
|
23
|
+
"""解析字面量值"""
|
|
24
|
+
if s in ("true", "True"):
|
|
25
|
+
return True
|
|
26
|
+
if s in ("false", "False"):
|
|
27
|
+
return False
|
|
28
|
+
if s in ("none", "None", "null"):
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return int(s)
|
|
32
|
+
except ValueError:
|
|
33
|
+
pass
|
|
34
|
+
try:
|
|
35
|
+
return float(s)
|
|
36
|
+
except ValueError:
|
|
37
|
+
pass
|
|
38
|
+
return s
|
|
39
|
+
|
|
40
|
+
|
|
19
41
|
@dataclass
|
|
20
42
|
class RetryPolicy:
|
|
21
43
|
"""节点重试策略"""
|
|
@@ -42,6 +64,7 @@ class Flow:
|
|
|
42
64
|
nodes: list[dict[str, Any]] = field(default_factory=list)
|
|
43
65
|
edges: list[dict[str, Any]] = field(default_factory=list)
|
|
44
66
|
variables: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
cycle_limits: dict[str, int] = field(default_factory=dict)
|
|
45
68
|
|
|
46
69
|
_node_handlers: dict[str, "NodeHandler"] = field(default_factory=dict, init=False, repr=False)
|
|
47
70
|
|
|
@@ -67,25 +90,46 @@ class Flow:
|
|
|
67
90
|
|
|
68
91
|
# ── 边驱动路由 ──
|
|
69
92
|
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
72
|
-
for
|
|
73
|
-
if
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
def _eval_condition(self, condition: str, context: "FlowContext") -> bool:
|
|
94
|
+
"""对边条件表达式求值"""
|
|
95
|
+
for op in _OPERATORS:
|
|
96
|
+
if op in condition:
|
|
97
|
+
left, right = condition.split(op, 1)
|
|
98
|
+
left_val = context.resolve_reference(left.strip())
|
|
99
|
+
right_val = _parse_literal(right.strip())
|
|
100
|
+
if op == "==":
|
|
101
|
+
return left_val == right_val
|
|
102
|
+
if op == "!=":
|
|
103
|
+
return left_val != right_val
|
|
104
|
+
try:
|
|
105
|
+
if op == ">":
|
|
106
|
+
return left_val > right_val
|
|
107
|
+
if op == "<":
|
|
108
|
+
return left_val < right_val
|
|
109
|
+
if op == ">=":
|
|
110
|
+
return left_val >= right_val
|
|
111
|
+
if op == "<=":
|
|
112
|
+
return left_val <= right_val
|
|
113
|
+
except TypeError:
|
|
114
|
+
return False
|
|
115
|
+
break
|
|
116
|
+
return bool(context.resolve_reference(condition.strip()))
|
|
117
|
+
|
|
118
|
+
def _resolve_next_node(self, current_id: str, context: "FlowContext", force_fallback: bool = False) -> str | None:
|
|
119
|
+
"""根据当前节点,通过 edges 表达式求值查找下一节点。
|
|
120
|
+
|
|
121
|
+
force_fallback=True 时跳过条件边,只走无条件边(用于循环超限逃逸)。
|
|
85
122
|
"""
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
123
|
+
fallback_target: str | None = None
|
|
124
|
+
for edge in self.edges:
|
|
125
|
+
if edge.get("source") != current_id:
|
|
126
|
+
continue
|
|
127
|
+
cond = edge.get("condition")
|
|
128
|
+
if cond is None:
|
|
129
|
+
fallback_target = edge.get("target")
|
|
130
|
+
elif not force_fallback and self._eval_condition(cond, context):
|
|
131
|
+
return edge.get("target")
|
|
132
|
+
return fallback_target
|
|
89
133
|
|
|
90
134
|
# ── message 节点 ──
|
|
91
135
|
|
|
@@ -102,6 +146,34 @@ class Flow:
|
|
|
102
146
|
|
|
103
147
|
# ── 带重试的节点执行(统一走 NodeHandler) ──
|
|
104
148
|
|
|
149
|
+
def _resolve_visibility(self, node_type: str, config: dict) -> tuple[str | None, bool]:
|
|
150
|
+
"""从 node config 和 registry 解析可见性(label, echo)
|
|
151
|
+
|
|
152
|
+
优先级:flow node config > registry 注册属性
|
|
153
|
+
"""
|
|
154
|
+
from .registry import registry
|
|
155
|
+
|
|
156
|
+
label = config.get("label")
|
|
157
|
+
explicit_echo = "echo" in config
|
|
158
|
+
echo = config.get("echo", False)
|
|
159
|
+
|
|
160
|
+
if not explicit_echo and label is None:
|
|
161
|
+
if node_type == "agent":
|
|
162
|
+
agent_name = config.get("agent_name", "")
|
|
163
|
+
label = registry.get_agent_label(agent_name)
|
|
164
|
+
if not label:
|
|
165
|
+
echo = registry.get_agent_echo(agent_name)
|
|
166
|
+
elif node_type == "tool":
|
|
167
|
+
tool_name = config.get("tool_name", "")
|
|
168
|
+
label = registry.get_tool_label(tool_name)
|
|
169
|
+
if not label:
|
|
170
|
+
echo = registry.get_tool_echo(tool_name)
|
|
171
|
+
|
|
172
|
+
if label and not explicit_echo:
|
|
173
|
+
echo = True
|
|
174
|
+
|
|
175
|
+
return label, echo
|
|
176
|
+
|
|
105
177
|
async def _execute_node_with_retry(
|
|
106
178
|
self,
|
|
107
179
|
node: dict,
|
|
@@ -120,6 +192,7 @@ class Flow:
|
|
|
120
192
|
|
|
121
193
|
policy = self._get_retry_policy(node)
|
|
122
194
|
label = handler.get_step_name(node, node_id)
|
|
195
|
+
vis_label, vis_echo = self._resolve_visibility(node_type, node.get("config", {}))
|
|
123
196
|
last_error: Exception | None = None
|
|
124
197
|
|
|
125
198
|
for attempt in range(policy.max_retries + 1):
|
|
@@ -140,6 +213,9 @@ class Flow:
|
|
|
140
213
|
)
|
|
141
214
|
|
|
142
215
|
async for evt in handler.stream(node, context, node_id):
|
|
216
|
+
evt["_node_id"] = node_id
|
|
217
|
+
evt["_label"] = vis_label
|
|
218
|
+
evt["_echo"] = vis_echo
|
|
143
219
|
yield evt
|
|
144
220
|
return
|
|
145
221
|
|
|
@@ -158,10 +234,16 @@ class Flow:
|
|
|
158
234
|
|
|
159
235
|
# ── 执行入口 ──
|
|
160
236
|
|
|
237
|
+
def _check_cycle_limit(self, node_id: str, visit_count: dict[str, int]) -> bool:
|
|
238
|
+
"""检查节点是否超出循环次数限制。返回 True 表示超限。"""
|
|
239
|
+
limit = self.cycle_limits.get(node_id)
|
|
240
|
+
if limit is not None and visit_count.get(node_id, 0) > limit:
|
|
241
|
+
return True
|
|
242
|
+
return False
|
|
243
|
+
|
|
161
244
|
async def run(self, context: "FlowContext") -> dict[str, Any]:
|
|
162
245
|
"""执行 Flow"""
|
|
163
246
|
if not self.edges:
|
|
164
|
-
# 向后兼容:无 edges 时按 nodes 列表顺序执行
|
|
165
247
|
for node in self.nodes:
|
|
166
248
|
node_id = node.get("id")
|
|
167
249
|
if not node_id:
|
|
@@ -175,12 +257,21 @@ class Flow:
|
|
|
175
257
|
else:
|
|
176
258
|
raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
|
|
177
259
|
else:
|
|
178
|
-
# edge 驱动执行
|
|
179
260
|
current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
|
|
261
|
+
visit_count: dict[str, int] = {}
|
|
262
|
+
|
|
180
263
|
while current_node_id:
|
|
181
264
|
node = self.get_node_config(current_node_id)
|
|
182
265
|
if not node:
|
|
183
266
|
break
|
|
267
|
+
|
|
268
|
+
# 循环计数与超限检测
|
|
269
|
+
visit_count[current_node_id] = visit_count.get(current_node_id, 0) + 1
|
|
270
|
+
force_fallback = self._check_cycle_limit(current_node_id, visit_count)
|
|
271
|
+
if force_fallback:
|
|
272
|
+
current_node_id = self._resolve_next_node(current_node_id, context, force_fallback=True)
|
|
273
|
+
continue
|
|
274
|
+
|
|
184
275
|
context.current_node = current_node_id
|
|
185
276
|
node_type: str = node.get("type", "")
|
|
186
277
|
|
|
@@ -189,7 +280,7 @@ class Flow:
|
|
|
189
280
|
template = config.get("content", "")
|
|
190
281
|
text = template.format_map(_SafeFormatDict(context.variables))
|
|
191
282
|
context.set_output(current_node_id, {"result": text})
|
|
192
|
-
current_node_id = self._resolve_next_node(current_node_id,
|
|
283
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
193
284
|
|
|
194
285
|
elif node_type == "parallel":
|
|
195
286
|
config = node.get("config", {})
|
|
@@ -207,8 +298,13 @@ class Flow:
|
|
|
207
298
|
context.set_output(branch_id, result)
|
|
208
299
|
|
|
209
300
|
await asyncio.gather(*[_run_branch(bid) for bid in branches])
|
|
210
|
-
|
|
211
|
-
|
|
301
|
+
merged: dict[str, Any] = {}
|
|
302
|
+
for bid in branches:
|
|
303
|
+
branch_result = context.outputs.get(bid, {})
|
|
304
|
+
if isinstance(branch_result, dict):
|
|
305
|
+
merged.update(branch_result)
|
|
306
|
+
context.set_output(current_node_id, merged)
|
|
307
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
212
308
|
|
|
213
309
|
elif node_type == "iteration":
|
|
214
310
|
config = node.get("config", {})
|
|
@@ -238,44 +334,13 @@ class Flow:
|
|
|
238
334
|
results.append(context.outputs.get(body_nodes[-1]))
|
|
239
335
|
|
|
240
336
|
context.set_output(current_node_id, {"results": results})
|
|
241
|
-
current_node_id = self._resolve_next_node(current_node_id,
|
|
242
|
-
|
|
243
|
-
elif node_type == "loop":
|
|
244
|
-
config = node.get("config", {})
|
|
245
|
-
body_nodes_l: list[str] = config.get("body", [])
|
|
246
|
-
condition_node_id = config.get("condition_node")
|
|
247
|
-
break_cond = config.get("break_condition", "done")
|
|
248
|
-
max_iter = config.get("max_iterations", 10)
|
|
249
|
-
loop_var = config.get("loop_variable", "loop_count")
|
|
250
|
-
|
|
251
|
-
for iteration in range(max_iter):
|
|
252
|
-
context.set_variable(loop_var, iteration)
|
|
253
|
-
for body_node_id in body_nodes_l:
|
|
254
|
-
body_node = self.get_node_config(body_node_id)
|
|
255
|
-
if not body_node:
|
|
256
|
-
continue
|
|
257
|
-
body_type: str = body_node.get("type", "")
|
|
258
|
-
body_handler = self._node_handlers.get(body_type)
|
|
259
|
-
if body_handler:
|
|
260
|
-
body_result = await body_handler.execute(body_node, context)
|
|
261
|
-
context.set_output(body_node_id, body_result)
|
|
262
|
-
if condition_node_id:
|
|
263
|
-
cond_result = context.outputs.get(condition_node_id, {})
|
|
264
|
-
if isinstance(cond_result, dict) and cond_result.get("choice") == break_cond:
|
|
265
|
-
break
|
|
266
|
-
|
|
267
|
-
context.set_output(current_node_id, {"choice": "done"})
|
|
268
|
-
current_node_id = self._resolve_next_node(current_node_id, "done")
|
|
337
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
269
338
|
|
|
270
339
|
elif node_type in self._node_handlers:
|
|
271
|
-
# 所有执行类节点统一分发
|
|
272
340
|
handler = self._node_handlers[node_type]
|
|
273
341
|
result = await handler.execute(node, context)
|
|
274
342
|
context.set_output(current_node_id, result)
|
|
275
|
-
|
|
276
|
-
current_node_id = self._resolve_next_node(current_node_id, route_key) or self._resolve_next_node(
|
|
277
|
-
current_node_id, "done"
|
|
278
|
-
)
|
|
343
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
279
344
|
|
|
280
345
|
else:
|
|
281
346
|
raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
|
|
@@ -298,7 +363,7 @@ class Flow:
|
|
|
298
363
|
yield event.step_finished(step_name=f"flow:{self.name}")
|
|
299
364
|
|
|
300
365
|
async def _stream_sequential(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
|
|
301
|
-
"""
|
|
366
|
+
"""顺序流式执行"""
|
|
302
367
|
for node in self.nodes:
|
|
303
368
|
node_id = node.get("id")
|
|
304
369
|
if not node_id:
|
|
@@ -320,6 +385,7 @@ class Flow:
|
|
|
320
385
|
async def _stream_edge_driven(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
|
|
321
386
|
"""边驱动流式执行"""
|
|
322
387
|
current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
|
|
388
|
+
visit_count: dict[str, int] = {}
|
|
323
389
|
|
|
324
390
|
while current_node_id:
|
|
325
391
|
node = self.get_node_config(current_node_id)
|
|
@@ -330,18 +396,32 @@ class Flow:
|
|
|
330
396
|
)
|
|
331
397
|
raise NodeExecutionError("NODE_NOT_FOUND", args={"node_id": current_node_id})
|
|
332
398
|
|
|
399
|
+
# 循环计数与超限检测
|
|
400
|
+
visit_count[current_node_id] = visit_count.get(current_node_id, 0) + 1
|
|
401
|
+
force_fallback = self._check_cycle_limit(current_node_id, visit_count)
|
|
402
|
+
if force_fallback:
|
|
403
|
+
current_node_id = self._resolve_next_node(current_node_id, context, force_fallback=True)
|
|
404
|
+
continue
|
|
405
|
+
|
|
333
406
|
context.current_node = current_node_id
|
|
334
407
|
node_type: str = node.get("type", "")
|
|
335
408
|
|
|
336
409
|
if node_type == "message":
|
|
337
410
|
async for evt in self._emit_message(node, context):
|
|
411
|
+
evt["_node_id"] = current_node_id
|
|
412
|
+
evt["_label"] = None
|
|
413
|
+
evt["_echo"] = False
|
|
338
414
|
yield evt
|
|
339
|
-
current_node_id = self._resolve_next_node(current_node_id,
|
|
415
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
340
416
|
|
|
341
417
|
elif node_type == "parallel":
|
|
342
418
|
config = node.get("config", {})
|
|
343
419
|
branches = config.get("branches", [])
|
|
344
|
-
|
|
420
|
+
step_evt = event.step_started(step_name=f"parallel:{current_node_id}")
|
|
421
|
+
step_evt["_node_id"] = current_node_id
|
|
422
|
+
step_evt["_label"] = None
|
|
423
|
+
step_evt["_echo"] = False
|
|
424
|
+
yield step_evt
|
|
345
425
|
|
|
346
426
|
async def _exec_branch(branch_id: str) -> None:
|
|
347
427
|
branch_node = self.get_node_config(branch_id)
|
|
@@ -355,9 +435,18 @@ class Flow:
|
|
|
355
435
|
context.set_output(branch_id, result)
|
|
356
436
|
|
|
357
437
|
await asyncio.gather(*[_exec_branch(bid) for bid in branches])
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
438
|
+
merged: dict[str, Any] = {}
|
|
439
|
+
for bid in branches:
|
|
440
|
+
branch_result = context.outputs.get(bid, {})
|
|
441
|
+
if isinstance(branch_result, dict):
|
|
442
|
+
merged.update(branch_result)
|
|
443
|
+
context.set_output(current_node_id, merged)
|
|
444
|
+
fin_evt = event.step_finished(step_name=f"parallel:{current_node_id}")
|
|
445
|
+
fin_evt["_node_id"] = current_node_id
|
|
446
|
+
fin_evt["_label"] = None
|
|
447
|
+
fin_evt["_echo"] = False
|
|
448
|
+
yield fin_evt
|
|
449
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
361
450
|
|
|
362
451
|
elif node_type == "iteration":
|
|
363
452
|
config = node.get("config", {})
|
|
@@ -371,7 +460,11 @@ class Flow:
|
|
|
371
460
|
body_nodes: list[str] = config.get("body", [])
|
|
372
461
|
results: list[Any] = []
|
|
373
462
|
|
|
374
|
-
|
|
463
|
+
step_evt = event.step_started(step_name=f"iteration:{current_node_id}")
|
|
464
|
+
step_evt["_node_id"] = current_node_id
|
|
465
|
+
step_evt["_label"] = None
|
|
466
|
+
step_evt["_echo"] = False
|
|
467
|
+
yield step_evt
|
|
375
468
|
for idx, item in enumerate(items):
|
|
376
469
|
context.set_variable(item_var, item)
|
|
377
470
|
context.set_variable(index_var, idx)
|
|
@@ -388,48 +481,17 @@ class Flow:
|
|
|
388
481
|
results.append(context.outputs.get(body_nodes[-1]))
|
|
389
482
|
|
|
390
483
|
context.set_output(current_node_id, {"results": results})
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
condition_node_id = config.get("condition_node")
|
|
398
|
-
break_cond = config.get("break_condition", "done")
|
|
399
|
-
max_iter = config.get("max_iterations", 10)
|
|
400
|
-
loop_var = config.get("loop_variable", "loop_count")
|
|
401
|
-
|
|
402
|
-
yield event.step_started(step_name=f"loop:{current_node_id}")
|
|
403
|
-
for iteration in range(max_iter):
|
|
404
|
-
context.set_variable(loop_var, iteration)
|
|
405
|
-
for body_node_id in body_nodes_l:
|
|
406
|
-
body_node = self.get_node_config(body_node_id)
|
|
407
|
-
if not body_node:
|
|
408
|
-
continue
|
|
409
|
-
body_type = body_node.get("type", "")
|
|
410
|
-
body_handler = self._node_handlers.get(body_type)
|
|
411
|
-
if body_handler:
|
|
412
|
-
body_result = await body_handler.execute(body_node, context)
|
|
413
|
-
context.set_output(body_node_id, body_result)
|
|
414
|
-
# 检查终止条件
|
|
415
|
-
if condition_node_id:
|
|
416
|
-
cond_result = context.outputs.get(condition_node_id, {})
|
|
417
|
-
if isinstance(cond_result, dict) and cond_result.get("choice") == break_cond:
|
|
418
|
-
break
|
|
419
|
-
|
|
420
|
-
context.set_output(current_node_id, {"choice": "done"})
|
|
421
|
-
yield event.step_finished(step_name=f"loop:{current_node_id}")
|
|
422
|
-
current_node_id = self._resolve_next_node(current_node_id, "done")
|
|
484
|
+
fin_evt = event.step_finished(step_name=f"iteration:{current_node_id}")
|
|
485
|
+
fin_evt["_node_id"] = current_node_id
|
|
486
|
+
fin_evt["_label"] = None
|
|
487
|
+
fin_evt["_echo"] = False
|
|
488
|
+
yield fin_evt
|
|
489
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
423
490
|
|
|
424
491
|
elif node_type in self._node_handlers:
|
|
425
|
-
# 所有执行类节点统一分发
|
|
426
492
|
async for evt in self._execute_node_with_retry(node, context, current_node_id):
|
|
427
493
|
yield evt
|
|
428
|
-
|
|
429
|
-
route_key = self._extract_route_key(result)
|
|
430
|
-
current_node_id = self._resolve_next_node(current_node_id, route_key) or self._resolve_next_node(
|
|
431
|
-
current_node_id, "done"
|
|
432
|
-
)
|
|
494
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
433
495
|
|
|
434
496
|
else:
|
|
435
497
|
yield event.run_error(
|
|
@@ -19,13 +19,17 @@ class AgentNodeHandler(NodeHandler):
|
|
|
19
19
|
|
|
20
20
|
node_type = "agent"
|
|
21
21
|
|
|
22
|
-
_NODE_KEYS = frozenset({"agent_name", "inputs"})
|
|
22
|
+
_NODE_KEYS = frozenset({"agent_name", "inputs", "label", "echo"})
|
|
23
23
|
|
|
24
|
-
def _create_agent(self, config: dict):
|
|
24
|
+
def _create_agent(self, config: dict, context: "FlowContext"):
|
|
25
25
|
agent_name = config.get("agent_name")
|
|
26
26
|
if not agent_name:
|
|
27
27
|
raise FlowError("MISSING_AGENT_NAME", 400)
|
|
28
|
-
agent_kwargs = {
|
|
28
|
+
agent_kwargs = {
|
|
29
|
+
k: context.resolve_reference(v) if isinstance(v, str) else v
|
|
30
|
+
for k, v in config.items()
|
|
31
|
+
if k not in self._NODE_KEYS
|
|
32
|
+
}
|
|
29
33
|
agent = registry.create_agent(agent_name, **agent_kwargs)
|
|
30
34
|
if not agent:
|
|
31
35
|
raise FlowError("AGENT_NOT_FOUND", 404, {"agent_name": agent_name})
|
|
@@ -34,8 +38,10 @@ class AgentNodeHandler(NodeHandler):
|
|
|
34
38
|
async def execute(self, node: dict, context: "FlowContext") -> Any:
|
|
35
39
|
config = node.get("config", {})
|
|
36
40
|
resolved = self.resolve_inputs(config, context)
|
|
37
|
-
ag = self._create_agent(config)
|
|
38
|
-
|
|
41
|
+
ag = self._create_agent(config, context)
|
|
42
|
+
result = await ag.run(context, inputs=resolved)
|
|
43
|
+
structured = context.outputs.pop(ag.name, None)
|
|
44
|
+
return structured if structured is not None else result
|
|
39
45
|
|
|
40
46
|
async def stream(self, node: dict, context: "FlowContext", node_id: str) -> AsyncIterator[dict[str, Any]]:
|
|
41
47
|
config = node.get("config", {})
|
|
@@ -43,9 +49,13 @@ class AgentNodeHandler(NodeHandler):
|
|
|
43
49
|
|
|
44
50
|
yield event.step_started(step_name=step_name)
|
|
45
51
|
resolved = self.resolve_inputs(config, context)
|
|
46
|
-
ag = self._create_agent(config)
|
|
52
|
+
ag = self._create_agent(config, context)
|
|
47
53
|
async for evt in ag.stream(context, inputs=resolved):
|
|
48
54
|
yield evt
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
structured = context.outputs.pop(ag.name, None)
|
|
56
|
+
if structured is not None:
|
|
57
|
+
context.set_output(node_id, structured)
|
|
58
|
+
else:
|
|
59
|
+
result = context.get_last_output(ag.name) or ""
|
|
60
|
+
context.set_output(node_id, {"result": result})
|
|
51
61
|
yield event.step_finished(step_name=step_name)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.16.0
|
|
4
4
|
Summary: Production-ready toolkit for building FastAPI and LLM applications
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -130,28 +130,51 @@ class TestTool:
|
|
|
130
130
|
assert result.error is not None and "boom" in result.error
|
|
131
131
|
|
|
132
132
|
|
|
133
|
-
# ──
|
|
133
|
+
# ── _eval_condition ──
|
|
134
134
|
|
|
135
135
|
|
|
136
|
-
class
|
|
137
|
-
"""
|
|
136
|
+
class TestEvalCondition:
|
|
137
|
+
"""条件表达式求值测试"""
|
|
138
138
|
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
assert
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
assert
|
|
150
|
-
assert
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
139
|
+
def test_eq_string(self):
|
|
140
|
+
flow = Flow(flow_id="t", name="t")
|
|
141
|
+
ctx = FlowContext()
|
|
142
|
+
ctx.set_output("detect1", {"choice": "qa"})
|
|
143
|
+
assert flow._eval_condition("$o.detect1.choice == qa", ctx) is True
|
|
144
|
+
assert flow._eval_condition("$o.detect1.choice == chitchat", ctx) is False
|
|
145
|
+
|
|
146
|
+
def test_eq_bool(self):
|
|
147
|
+
flow = Flow(flow_id="t", name="t")
|
|
148
|
+
ctx = FlowContext(variables={"has_context": True})
|
|
149
|
+
assert flow._eval_condition("$v.has_context == true", ctx) is True
|
|
150
|
+
assert flow._eval_condition("$v.has_context == false", ctx) is False
|
|
151
|
+
|
|
152
|
+
def test_numeric_comparison(self):
|
|
153
|
+
flow = Flow(flow_id="t", name="t")
|
|
154
|
+
ctx = FlowContext()
|
|
155
|
+
ctx.set_output("eval", {"score": 0.9})
|
|
156
|
+
assert flow._eval_condition("$o.eval.score > 0.8", ctx) is True
|
|
157
|
+
assert flow._eval_condition("$o.eval.score < 0.8", ctx) is False
|
|
158
|
+
assert flow._eval_condition("$o.eval.score >= 0.9", ctx) is True
|
|
159
|
+
assert flow._eval_condition("$o.eval.score <= 0.9", ctx) is True
|
|
160
|
+
|
|
161
|
+
def test_neq(self):
|
|
162
|
+
flow = Flow(flow_id="t", name="t")
|
|
163
|
+
ctx = FlowContext(variables={"status": "error"})
|
|
164
|
+
assert flow._eval_condition("$v.status != ok", ctx) is True
|
|
165
|
+
assert flow._eval_condition("$v.status != error", ctx) is False
|
|
166
|
+
|
|
167
|
+
def test_truthy_check(self):
|
|
168
|
+
flow = Flow(flow_id="t", name="t")
|
|
169
|
+
ctx = FlowContext(variables={"flag": True, "empty": ""})
|
|
170
|
+
assert flow._eval_condition("$v.flag", ctx) is True
|
|
171
|
+
assert flow._eval_condition("$v.empty", ctx) is False
|
|
172
|
+
assert flow._eval_condition("$v.missing", ctx) is False
|
|
173
|
+
|
|
174
|
+
def test_type_error_returns_false(self):
|
|
175
|
+
flow = Flow(flow_id="t", name="t")
|
|
176
|
+
ctx = FlowContext(variables={"name": "alice"})
|
|
177
|
+
assert flow._eval_condition("$v.name > 5", ctx) is False
|
|
155
178
|
|
|
156
179
|
|
|
157
180
|
# ── NodeHandler.resolve_inputs ──
|
|
@@ -460,33 +483,66 @@ class TestLLMChatNodeHandler:
|
|
|
460
483
|
|
|
461
484
|
|
|
462
485
|
class TestFlowRouting:
|
|
463
|
-
"""Flow
|
|
486
|
+
"""Flow 路由测试"""
|
|
464
487
|
|
|
465
|
-
def
|
|
488
|
+
def test_resolve_next_node_with_expression(self):
|
|
466
489
|
flow = Flow(
|
|
467
490
|
flow_id="test",
|
|
468
491
|
name="test",
|
|
469
492
|
edges=[
|
|
470
|
-
{"source": "detect1", "condition": "qa", "target": "qa_node"},
|
|
471
|
-
{"source": "detect1", "condition": "chitchat", "target": "chat_node"},
|
|
472
|
-
{"source": "detect1", "
|
|
493
|
+
{"source": "detect1", "condition": "$o.detect1.choice == qa", "target": "qa_node"},
|
|
494
|
+
{"source": "detect1", "condition": "$o.detect1.choice == chitchat", "target": "chat_node"},
|
|
495
|
+
{"source": "detect1", "target": "end_node"},
|
|
473
496
|
],
|
|
474
497
|
)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
assert flow._resolve_next_node("detect1",
|
|
498
|
+
ctx = FlowContext()
|
|
499
|
+
ctx.set_output("detect1", {"choice": "qa"})
|
|
500
|
+
assert flow._resolve_next_node("detect1", ctx) == "qa_node"
|
|
501
|
+
|
|
502
|
+
ctx.set_output("detect1", {"choice": "chitchat"})
|
|
503
|
+
assert flow._resolve_next_node("detect1", ctx) == "chat_node"
|
|
504
|
+
|
|
505
|
+
ctx.set_output("detect1", {"choice": "unknown"})
|
|
506
|
+
assert flow._resolve_next_node("detect1", ctx) == "end_node"
|
|
478
507
|
|
|
479
|
-
def
|
|
480
|
-
"""
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
508
|
+
def test_fallback_edge(self):
|
|
509
|
+
"""无条件边作为 fallback"""
|
|
510
|
+
flow = Flow(
|
|
511
|
+
flow_id="test",
|
|
512
|
+
name="test",
|
|
513
|
+
edges=[
|
|
514
|
+
{"source": "node1", "condition": "$v.x == yes", "target": "branch_a"},
|
|
515
|
+
{"source": "node1", "target": "branch_b"},
|
|
516
|
+
],
|
|
517
|
+
)
|
|
518
|
+
ctx = FlowContext(variables={"x": "no"})
|
|
519
|
+
assert flow._resolve_next_node("node1", ctx) == "branch_b"
|
|
520
|
+
|
|
521
|
+
def test_no_matching_edge(self):
|
|
522
|
+
"""所有条件都不满足且无 fallback 时返回 None"""
|
|
523
|
+
flow = Flow(
|
|
524
|
+
flow_id="test",
|
|
525
|
+
name="test",
|
|
526
|
+
edges=[
|
|
527
|
+
{"source": "node1", "condition": "$v.x == yes", "target": "branch_a"},
|
|
528
|
+
],
|
|
529
|
+
)
|
|
530
|
+
ctx = FlowContext(variables={"x": "no"})
|
|
531
|
+
assert flow._resolve_next_node("node1", ctx) is None
|
|
484
532
|
|
|
485
|
-
def
|
|
486
|
-
"""
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
533
|
+
def test_cycle_limit_forces_fallback(self):
|
|
534
|
+
"""循环超限时强制走 fallback"""
|
|
535
|
+
flow = Flow(
|
|
536
|
+
flow_id="test",
|
|
537
|
+
name="test",
|
|
538
|
+
edges=[
|
|
539
|
+
{"source": "loop_node", "condition": "$v.done == false", "target": "loop_node"},
|
|
540
|
+
{"source": "loop_node", "target": "exit_node"},
|
|
541
|
+
],
|
|
542
|
+
)
|
|
543
|
+
ctx = FlowContext(variables={"done": False})
|
|
544
|
+
# force_fallback=True 时跳过条件边
|
|
545
|
+
assert flow._resolve_next_node("loop_node", ctx, force_fallback=True) == "exit_node"
|
|
490
546
|
|
|
491
547
|
|
|
492
548
|
# ── Full data flow integration ──
|
|
@@ -658,3 +714,123 @@ class TestLLMEmbedNodeHandler:
|
|
|
658
714
|
asyncio.get_event_loop().run_until_complete(handler.execute(node, ctx))
|
|
659
715
|
call_args = mock_client.embed.call_args
|
|
660
716
|
assert call_args.kwargs["model"] == "bge-m3"
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ── Cycle limits integration ──
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
class TestCycleLimits:
|
|
723
|
+
"""图级别循环边 + cycle_limits 集成测试"""
|
|
724
|
+
|
|
725
|
+
def test_cycle_terminates_at_limit(self):
|
|
726
|
+
"""循环边在达到 cycle_limits 时强制走 fallback"""
|
|
727
|
+
flow = Flow(
|
|
728
|
+
flow_id="test",
|
|
729
|
+
name="cycle_test",
|
|
730
|
+
nodes=[
|
|
731
|
+
{
|
|
732
|
+
"id": "counter",
|
|
733
|
+
"type": "python",
|
|
734
|
+
"config": {
|
|
735
|
+
"code": ("def main(n=None, **kwargs):\n return {'n': (n if n is not None else 0) + 1}"),
|
|
736
|
+
"inputs": {"n": "$o.counter.n"},
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
"id": "done",
|
|
741
|
+
"type": "python",
|
|
742
|
+
"config": {"code": "def main(**kwargs):\n return {'finished': True}"},
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
edges=[
|
|
746
|
+
{"source": "counter", "condition": "$o.counter.n < 100", "target": "counter"},
|
|
747
|
+
{"source": "counter", "target": "done"},
|
|
748
|
+
],
|
|
749
|
+
cycle_limits={"counter": 3},
|
|
750
|
+
)
|
|
751
|
+
ctx = FlowContext()
|
|
752
|
+
asyncio.get_event_loop().run_until_complete(flow.run(ctx))
|
|
753
|
+
# counter 最多执行 3 次,第 4 次进入时触发超限走 fallback
|
|
754
|
+
assert ctx.outputs["counter"]["n"] == 3
|
|
755
|
+
assert ctx.outputs["done"] == {"finished": True}
|
|
756
|
+
|
|
757
|
+
def test_cycle_without_limit_follows_condition(self):
|
|
758
|
+
"""无 cycle_limits 时正常按条件退出循环"""
|
|
759
|
+
flow = Flow(
|
|
760
|
+
flow_id="test",
|
|
761
|
+
name="natural_exit",
|
|
762
|
+
nodes=[
|
|
763
|
+
{
|
|
764
|
+
"id": "inc",
|
|
765
|
+
"type": "python",
|
|
766
|
+
"config": {
|
|
767
|
+
"code": ("def main(n=None, **kwargs):\n return {'n': (n if n is not None else 0) + 1}"),
|
|
768
|
+
"inputs": {"n": "$o.inc.n"},
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
"id": "end",
|
|
773
|
+
"type": "python",
|
|
774
|
+
"config": {"code": "def main(**kwargs):\n return {'end': True}"},
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
edges=[
|
|
778
|
+
{"source": "inc", "condition": "$o.inc.n < 5", "target": "inc"},
|
|
779
|
+
{"source": "inc", "target": "end"},
|
|
780
|
+
],
|
|
781
|
+
)
|
|
782
|
+
ctx = FlowContext()
|
|
783
|
+
asyncio.get_event_loop().run_until_complete(flow.run(ctx))
|
|
784
|
+
assert ctx.outputs["inc"]["n"] == 5
|
|
785
|
+
assert ctx.outputs["end"] == {"end": True}
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# ── Parallel merge integration ──
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
class TestParallelMerge:
|
|
792
|
+
"""并行节点自动合并测试"""
|
|
793
|
+
|
|
794
|
+
def test_parallel_auto_merges_branch_results(self):
|
|
795
|
+
"""parallel 节点各分支的 dict 结果自动合并"""
|
|
796
|
+
flow = Flow(
|
|
797
|
+
flow_id="test",
|
|
798
|
+
name="parallel_merge",
|
|
799
|
+
nodes=[
|
|
800
|
+
{
|
|
801
|
+
"id": "par",
|
|
802
|
+
"type": "parallel",
|
|
803
|
+
"config": {"branches": ["kw_search", "vec_search"]},
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
"id": "kw_search",
|
|
807
|
+
"type": "python",
|
|
808
|
+
"config": {"code": "def main(**kwargs):\n return {'keyword_results': [1, 2]}"},
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
"id": "vec_search",
|
|
812
|
+
"type": "python",
|
|
813
|
+
"config": {"code": "def main(**kwargs):\n return {'vector_results': [3, 4]}"},
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
"id": "merge_consumer",
|
|
817
|
+
"type": "python",
|
|
818
|
+
"config": {
|
|
819
|
+
"code": (
|
|
820
|
+
"def main(kw=None, vec=None, **kwargs):\n return {'combined': (kw or []) + (vec or [])}"
|
|
821
|
+
),
|
|
822
|
+
"inputs": {
|
|
823
|
+
"kw": "$o.par.keyword_results",
|
|
824
|
+
"vec": "$o.par.vector_results",
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
edges=[
|
|
830
|
+
{"source": "par", "target": "merge_consumer"},
|
|
831
|
+
],
|
|
832
|
+
)
|
|
833
|
+
ctx = FlowContext()
|
|
834
|
+
asyncio.get_event_loop().run_until_complete(flow.run(ctx))
|
|
835
|
+
assert ctx.outputs["par"] == {"keyword_results": [1, 2], "vector_results": [3, 4]}
|
|
836
|
+
assert ctx.outputs["merge_consumer"] == {"combined": [1, 2, 3, 4]}
|
|
@@ -293,8 +293,8 @@ class TestSwitchEdgeRouting:
|
|
|
293
293
|
},
|
|
294
294
|
],
|
|
295
295
|
edges=[
|
|
296
|
-
{"source": "model_switch", "
|
|
297
|
-
{"source": "model_switch", "
|
|
296
|
+
{"source": "model_switch", "condition": "$o.model_switch.choice == strong", "target": "agent_a"},
|
|
297
|
+
{"source": "model_switch", "condition": "$o.model_switch.choice == basic", "target": "pipeline_b"},
|
|
298
298
|
],
|
|
299
299
|
)
|
|
300
300
|
|
|
@@ -330,8 +330,8 @@ class TestSwitchEdgeRouting:
|
|
|
330
330
|
},
|
|
331
331
|
],
|
|
332
332
|
edges=[
|
|
333
|
-
{"source": "sw", "
|
|
334
|
-
{"source": "sw", "
|
|
333
|
+
{"source": "sw", "condition": "$o.sw.choice == a", "target": "node_a"},
|
|
334
|
+
{"source": "sw", "condition": "$o.sw.choice == b", "target": "node_b"},
|
|
335
335
|
],
|
|
336
336
|
)
|
|
337
337
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|