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.
Files changed (64) hide show
  1. {agstack-1.14.0 → agstack-1.15.0}/PKG-INFO +1 -1
  2. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/flow.py +108 -97
  3. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/loader.py +1 -0
  4. {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/PKG-INFO +1 -1
  5. {agstack-1.14.0 → agstack-1.15.0}/pyproject.toml +1 -1
  6. {agstack-1.14.0 → agstack-1.15.0}/tests/test_flow_io.py +213 -37
  7. {agstack-1.14.0 → agstack-1.15.0}/tests/test_flow_switch_subflow.py +4 -4
  8. {agstack-1.14.0 → agstack-1.15.0}/LICENSE +0 -0
  9. {agstack-1.14.0 → agstack-1.15.0}/README.md +0 -0
  10. {agstack-1.14.0 → agstack-1.15.0}/agstack/__init__.py +0 -0
  11. {agstack-1.14.0 → agstack-1.15.0}/agstack/config/__init__.py +0 -0
  12. {agstack-1.14.0 → agstack-1.15.0}/agstack/config/logger.py +0 -0
  13. {agstack-1.14.0 → agstack-1.15.0}/agstack/config/manager.py +0 -0
  14. {agstack-1.14.0 → agstack-1.15.0}/agstack/config/types.py +0 -0
  15. {agstack-1.14.0 → agstack-1.15.0}/agstack/contexts.py +0 -0
  16. {agstack-1.14.0 → agstack-1.15.0}/agstack/decorators.py +0 -0
  17. {agstack-1.14.0 → agstack-1.15.0}/agstack/events.py +0 -0
  18. {agstack-1.14.0 → agstack-1.15.0}/agstack/exceptions.py +0 -0
  19. {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/__init__.py +0 -0
  20. {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/exception.py +0 -0
  21. {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/middleware.py +0 -0
  22. {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/offline.py +0 -0
  23. {agstack-1.14.0 → agstack-1.15.0}/agstack/fastapi/sse.py +0 -0
  24. {agstack-1.14.0 → agstack-1.15.0}/agstack/infra/db/__init__.py +0 -0
  25. {agstack-1.14.0 → agstack-1.15.0}/agstack/infra/es/__init__.py +0 -0
  26. {agstack-1.14.0 → agstack-1.15.0}/agstack/infra/kg/__init__.py +0 -0
  27. {agstack-1.14.0 → agstack-1.15.0}/agstack/infra/mq/__init__.py +0 -0
  28. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/__init__.py +0 -0
  29. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/client.py +0 -0
  30. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/__init__.py +0 -0
  31. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/agent.py +0 -0
  32. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/context.py +0 -0
  33. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/event.py +0 -0
  34. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/exceptions.py +0 -0
  35. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/factory.py +0 -0
  36. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/__init__.py +0 -0
  37. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/agent_node.py +0 -0
  38. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/base.py +0 -0
  39. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
  40. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
  41. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
  42. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
  43. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
  44. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/python_node.py +0 -0
  45. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
  46. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
  47. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
  48. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/records.py +0 -0
  49. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/registry.py +0 -0
  50. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/sandbox.py +0 -0
  51. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/state.py +0 -0
  52. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/flow/tool.py +0 -0
  53. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/prompts.py +0 -0
  54. {agstack-1.14.0 → agstack-1.15.0}/agstack/llm/token.py +0 -0
  55. {agstack-1.14.0 → agstack-1.15.0}/agstack/schema.py +0 -0
  56. {agstack-1.14.0 → agstack-1.15.0}/agstack/security/__init__.py +0 -0
  57. {agstack-1.14.0 → agstack-1.15.0}/agstack/security/casbin.py +0 -0
  58. {agstack-1.14.0 → agstack-1.15.0}/agstack/security/crypt.py +0 -0
  59. {agstack-1.14.0 → agstack-1.15.0}/agstack/status.py +0 -0
  60. {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/SOURCES.txt +0 -0
  61. {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/dependency_links.txt +0 -0
  62. {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/requires.txt +0 -0
  63. {agstack-1.14.0 → agstack-1.15.0}/agstack.egg-info/top_level.txt +0 -0
  64. {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.14.0
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 _resolve_next_node(self, current_id: str, result: str | None = None) -> str | None:
71
- """根据当前节点和执行结果,通过 edges 查找下一节点"""
72
- for edge in self.edges:
73
- if edge.get("source") == current_id:
74
- cond = edge.get("condition")
75
- if cond is None or cond == result:
76
- return edge.get("target")
77
- return None
78
-
79
- @staticmethod
80
- def _extract_route_key(result: Any) -> str:
81
- """从节点输出 dict 中提取路由键。
82
-
83
- 节点输出 dict 中若包含 ``choice`` 字段,即为路由键。
84
- 没有 ``choice`` 则默认 ``"done"``。
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
- if isinstance(result, dict):
87
- return str(result.get("choice", "done"))
88
- return "done"
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, "done")
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
- context.set_output(current_node_id, {"choice": "done"})
211
- current_node_id = self._resolve_next_node(current_node_id, "done")
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, "done")
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
- route_key = self._extract_route_key(result)
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, "done")
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
- context.set_output(current_node_id, {"choice": "done"})
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, "done")
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, "done")
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
- result = context.outputs.get(current_node_id, {})
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(
@@ -60,4 +60,5 @@ class FlowLoader:
60
60
  nodes=nodes,
61
61
  edges=config.get("edges", []),
62
62
  variables=config.get("variables", {}),
63
+ cycle_limits=config.get("cycle_limits", {}),
63
64
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.14.0
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agstack"
3
- version = "1.14.0"
3
+ version = "1.15.0"
4
4
  description = "Production-ready toolkit for building FastAPI and LLM applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -130,28 +130,51 @@ class TestTool:
130
130
  assert result.error is not None and "boom" in result.error
131
131
 
132
132
 
133
- # ── _extract_route_key ──
133
+ # ── _eval_condition ──
134
134
 
135
135
 
136
- class TestExtractRouteKey:
137
- """路由键提取测试"""
136
+ class TestEvalCondition:
137
+ """条件表达式求值测试"""
138
138
 
139
- def test_dict_with_choice(self):
140
- assert Flow._extract_route_key({"choice": "qa"}) == "qa"
141
-
142
- def test_dict_without_choice(self):
143
- assert Flow._extract_route_key({"result": "hello"}) == "done"
144
-
145
- def test_dict_empty(self):
146
- assert Flow._extract_route_key({}) == "done"
147
-
148
- def test_non_dict(self):
149
- assert Flow._extract_route_key("some string") == "done"
150
- assert Flow._extract_route_key(None) == "done"
151
- assert Flow._extract_route_key(42) == "done"
152
-
153
- def test_choice_as_non_string(self):
154
- assert Flow._extract_route_key({"choice": 123}) == "123"
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 路由和 loop break 测试"""
486
+ """Flow 路由测试"""
464
487
 
465
- def test_resolve_next_node_with_condition(self):
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", "condition": "done", "target": "end_node"},
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
- assert flow._resolve_next_node("detect1", "qa") == "qa_node"
476
- assert flow._resolve_next_node("detect1", "chitchat") == "chat_node"
477
- assert flow._resolve_next_node("detect1", "done") == "end_node"
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 test_route_key_from_detect_result(self):
480
- """detect 返回 {"choice": "qa"} 应该正确路由"""
481
- detect_result = {"choice": "qa"}
482
- route_key = Flow._extract_route_key(detect_result)
483
- assert route_key == "qa"
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 test_route_key_from_normal_result(self):
486
- """普通节点(无 choice)应该路由到 done"""
487
- normal_result = {"result": "some text"}
488
- route_key = Flow._extract_route_key(normal_result)
489
- assert route_key == "done"
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", "target": "agent_a", "condition": "strong"},
297
- {"source": "model_switch", "target": "pipeline_b", "condition": "basic"},
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", "target": "node_a", "condition": "a"},
334
- {"source": "sw", "target": "node_b", "condition": "b"},
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