agstack 1.14.0__tar.gz → 1.15.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.15.0}/PKG-INFO +1 -1
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/flow.py +108 -97
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/loader.py +1 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-1.14.0 → agstack-1.15.0}/pyproject.toml +1 -1
- {agstack-1.14.0 → agstack-1.15.0}/tests/test_flow_io.py +213 -37
- {agstack-1.14.0 → agstack-1.15.0}/tests/test_flow_switch_subflow.py +4 -4
- {agstack-1.14.0 → agstack-1.15.0}/LICENSE +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/README.md +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/config/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/config/logger.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/config/manager.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/config/types.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/contexts.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/decorators.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/events.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/exceptions.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/exception.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/middleware.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/offline.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/sse.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/infra/db/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/infra/es/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/infra/kg/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/infra/mq/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/client.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/agent.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/context.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/event.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/factory.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/agent_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/base.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/records.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/registry.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/state.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/tool.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/prompts.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/token.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/schema.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/security/__init__.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/security/casbin.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/security/crypt.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack/status.py +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/SOURCES.txt +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/requires.txt +0 -0
- {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/top_level.txt +0 -0
- {agstack-1.14.0 → agstack-1.15.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.15.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
|
|
|
@@ -158,10 +202,16 @@ class Flow:
|
|
|
158
202
|
|
|
159
203
|
# ── 执行入口 ──
|
|
160
204
|
|
|
205
|
+
def _check_cycle_limit(self, node_id: str, visit_count: dict[str, int]) -> bool:
|
|
206
|
+
"""检查节点是否超出循环次数限制。返回 True 表示超限。"""
|
|
207
|
+
limit = self.cycle_limits.get(node_id)
|
|
208
|
+
if limit is not None and visit_count.get(node_id, 0) > limit:
|
|
209
|
+
return True
|
|
210
|
+
return False
|
|
211
|
+
|
|
161
212
|
async def run(self, context: "FlowContext") -> dict[str, Any]:
|
|
162
213
|
"""执行 Flow"""
|
|
163
214
|
if not self.edges:
|
|
164
|
-
# 向后兼容:无 edges 时按 nodes 列表顺序执行
|
|
165
215
|
for node in self.nodes:
|
|
166
216
|
node_id = node.get("id")
|
|
167
217
|
if not node_id:
|
|
@@ -175,12 +225,21 @@ class Flow:
|
|
|
175
225
|
else:
|
|
176
226
|
raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
|
|
177
227
|
else:
|
|
178
|
-
# edge 驱动执行
|
|
179
228
|
current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
|
|
229
|
+
visit_count: dict[str, int] = {}
|
|
230
|
+
|
|
180
231
|
while current_node_id:
|
|
181
232
|
node = self.get_node_config(current_node_id)
|
|
182
233
|
if not node:
|
|
183
234
|
break
|
|
235
|
+
|
|
236
|
+
# 循环计数与超限检测
|
|
237
|
+
visit_count[current_node_id] = visit_count.get(current_node_id, 0) + 1
|
|
238
|
+
force_fallback = self._check_cycle_limit(current_node_id, visit_count)
|
|
239
|
+
if force_fallback:
|
|
240
|
+
current_node_id = self._resolve_next_node(current_node_id, context, force_fallback=True)
|
|
241
|
+
continue
|
|
242
|
+
|
|
184
243
|
context.current_node = current_node_id
|
|
185
244
|
node_type: str = node.get("type", "")
|
|
186
245
|
|
|
@@ -189,7 +248,7 @@ class Flow:
|
|
|
189
248
|
template = config.get("content", "")
|
|
190
249
|
text = template.format_map(_SafeFormatDict(context.variables))
|
|
191
250
|
context.set_output(current_node_id, {"result": text})
|
|
192
|
-
current_node_id = self._resolve_next_node(current_node_id,
|
|
251
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
193
252
|
|
|
194
253
|
elif node_type == "parallel":
|
|
195
254
|
config = node.get("config", {})
|
|
@@ -207,8 +266,13 @@ class Flow:
|
|
|
207
266
|
context.set_output(branch_id, result)
|
|
208
267
|
|
|
209
268
|
await asyncio.gather(*[_run_branch(bid) for bid in branches])
|
|
210
|
-
|
|
211
|
-
|
|
269
|
+
merged: dict[str, Any] = {}
|
|
270
|
+
for bid in branches:
|
|
271
|
+
branch_result = context.outputs.get(bid, {})
|
|
272
|
+
if isinstance(branch_result, dict):
|
|
273
|
+
merged.update(branch_result)
|
|
274
|
+
context.set_output(current_node_id, merged)
|
|
275
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
212
276
|
|
|
213
277
|
elif node_type == "iteration":
|
|
214
278
|
config = node.get("config", {})
|
|
@@ -238,44 +302,13 @@ class Flow:
|
|
|
238
302
|
results.append(context.outputs.get(body_nodes[-1]))
|
|
239
303
|
|
|
240
304
|
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")
|
|
305
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
269
306
|
|
|
270
307
|
elif node_type in self._node_handlers:
|
|
271
|
-
# 所有执行类节点统一分发
|
|
272
308
|
handler = self._node_handlers[node_type]
|
|
273
309
|
result = await handler.execute(node, context)
|
|
274
310
|
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
|
-
)
|
|
311
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
279
312
|
|
|
280
313
|
else:
|
|
281
314
|
raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
|
|
@@ -298,7 +331,7 @@ class Flow:
|
|
|
298
331
|
yield event.step_finished(step_name=f"flow:{self.name}")
|
|
299
332
|
|
|
300
333
|
async def _stream_sequential(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
|
|
301
|
-
"""
|
|
334
|
+
"""顺序流式执行"""
|
|
302
335
|
for node in self.nodes:
|
|
303
336
|
node_id = node.get("id")
|
|
304
337
|
if not node_id:
|
|
@@ -320,6 +353,7 @@ class Flow:
|
|
|
320
353
|
async def _stream_edge_driven(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
|
|
321
354
|
"""边驱动流式执行"""
|
|
322
355
|
current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
|
|
356
|
+
visit_count: dict[str, int] = {}
|
|
323
357
|
|
|
324
358
|
while current_node_id:
|
|
325
359
|
node = self.get_node_config(current_node_id)
|
|
@@ -330,13 +364,20 @@ class Flow:
|
|
|
330
364
|
)
|
|
331
365
|
raise NodeExecutionError("NODE_NOT_FOUND", args={"node_id": current_node_id})
|
|
332
366
|
|
|
367
|
+
# 循环计数与超限检测
|
|
368
|
+
visit_count[current_node_id] = visit_count.get(current_node_id, 0) + 1
|
|
369
|
+
force_fallback = self._check_cycle_limit(current_node_id, visit_count)
|
|
370
|
+
if force_fallback:
|
|
371
|
+
current_node_id = self._resolve_next_node(current_node_id, context, force_fallback=True)
|
|
372
|
+
continue
|
|
373
|
+
|
|
333
374
|
context.current_node = current_node_id
|
|
334
375
|
node_type: str = node.get("type", "")
|
|
335
376
|
|
|
336
377
|
if node_type == "message":
|
|
337
378
|
async for evt in self._emit_message(node, context):
|
|
338
379
|
yield evt
|
|
339
|
-
current_node_id = self._resolve_next_node(current_node_id,
|
|
380
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
340
381
|
|
|
341
382
|
elif node_type == "parallel":
|
|
342
383
|
config = node.get("config", {})
|
|
@@ -355,9 +396,14 @@ class Flow:
|
|
|
355
396
|
context.set_output(branch_id, result)
|
|
356
397
|
|
|
357
398
|
await asyncio.gather(*[_exec_branch(bid) for bid in branches])
|
|
358
|
-
|
|
399
|
+
merged: dict[str, Any] = {}
|
|
400
|
+
for bid in branches:
|
|
401
|
+
branch_result = context.outputs.get(bid, {})
|
|
402
|
+
if isinstance(branch_result, dict):
|
|
403
|
+
merged.update(branch_result)
|
|
404
|
+
context.set_output(current_node_id, merged)
|
|
359
405
|
yield event.step_finished(step_name=f"parallel:{current_node_id}")
|
|
360
|
-
current_node_id = self._resolve_next_node(current_node_id,
|
|
406
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
361
407
|
|
|
362
408
|
elif node_type == "iteration":
|
|
363
409
|
config = node.get("config", {})
|
|
@@ -389,47 +435,12 @@ class Flow:
|
|
|
389
435
|
|
|
390
436
|
context.set_output(current_node_id, {"results": results})
|
|
391
437
|
yield event.step_finished(step_name=f"iteration:{current_node_id}")
|
|
392
|
-
current_node_id = self._resolve_next_node(current_node_id,
|
|
393
|
-
|
|
394
|
-
elif node_type == "loop":
|
|
395
|
-
config = node.get("config", {})
|
|
396
|
-
body_nodes_l: list[str] = config.get("body", [])
|
|
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")
|
|
438
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
423
439
|
|
|
424
440
|
elif node_type in self._node_handlers:
|
|
425
|
-
# 所有执行类节点统一分发
|
|
426
441
|
async for evt in self._execute_node_with_retry(node, context, current_node_id):
|
|
427
442
|
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
|
-
)
|
|
443
|
+
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
433
444
|
|
|
434
445
|
else:
|
|
435
446
|
yield event.run_error(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.15.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
|
|
File without changes
|