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.
Files changed (64) hide show
  1. {agstack-1.14.0 → agstack-1.16.0}/PKG-INFO +1 -1
  2. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/flow.py +163 -101
  3. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/loader.py +1 -0
  4. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/agent_node.py +18 -8
  5. {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/PKG-INFO +1 -1
  6. {agstack-1.14.0 → agstack-1.16.0}/pyproject.toml +1 -1
  7. {agstack-1.14.0 → agstack-1.16.0}/tests/test_flow_io.py +213 -37
  8. {agstack-1.14.0 → agstack-1.16.0}/tests/test_flow_switch_subflow.py +4 -4
  9. {agstack-1.14.0 → agstack-1.16.0}/LICENSE +0 -0
  10. {agstack-1.14.0 → agstack-1.16.0}/README.md +0 -0
  11. {agstack-1.14.0 → agstack-1.16.0}/agstack/__init__.py +0 -0
  12. {agstack-1.14.0 → agstack-1.16.0}/agstack/config/__init__.py +0 -0
  13. {agstack-1.14.0 → agstack-1.16.0}/agstack/config/logger.py +0 -0
  14. {agstack-1.14.0 → agstack-1.16.0}/agstack/config/manager.py +0 -0
  15. {agstack-1.14.0 → agstack-1.16.0}/agstack/config/types.py +0 -0
  16. {agstack-1.14.0 → agstack-1.16.0}/agstack/contexts.py +0 -0
  17. {agstack-1.14.0 → agstack-1.16.0}/agstack/decorators.py +0 -0
  18. {agstack-1.14.0 → agstack-1.16.0}/agstack/events.py +0 -0
  19. {agstack-1.14.0 → agstack-1.16.0}/agstack/exceptions.py +0 -0
  20. {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/__init__.py +0 -0
  21. {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/exception.py +0 -0
  22. {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/middleware.py +0 -0
  23. {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/offline.py +0 -0
  24. {agstack-1.14.0 → agstack-1.16.0}/agstack/fastapi/sse.py +0 -0
  25. {agstack-1.14.0 → agstack-1.16.0}/agstack/infra/db/__init__.py +0 -0
  26. {agstack-1.14.0 → agstack-1.16.0}/agstack/infra/es/__init__.py +0 -0
  27. {agstack-1.14.0 → agstack-1.16.0}/agstack/infra/kg/__init__.py +0 -0
  28. {agstack-1.14.0 → agstack-1.16.0}/agstack/infra/mq/__init__.py +0 -0
  29. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/__init__.py +0 -0
  30. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/client.py +0 -0
  31. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/__init__.py +0 -0
  32. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/agent.py +0 -0
  33. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/context.py +0 -0
  34. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/event.py +0 -0
  35. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/exceptions.py +0 -0
  36. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/factory.py +0 -0
  37. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/__init__.py +0 -0
  38. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/base.py +0 -0
  39. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
  40. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
  41. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
  42. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
  43. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
  44. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/python_node.py +0 -0
  45. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
  46. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
  47. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
  48. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/records.py +0 -0
  49. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/registry.py +0 -0
  50. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/sandbox.py +0 -0
  51. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/state.py +0 -0
  52. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/flow/tool.py +0 -0
  53. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/prompts.py +0 -0
  54. {agstack-1.14.0 → agstack-1.16.0}/agstack/llm/token.py +0 -0
  55. {agstack-1.14.0 → agstack-1.16.0}/agstack/schema.py +0 -0
  56. {agstack-1.14.0 → agstack-1.16.0}/agstack/security/__init__.py +0 -0
  57. {agstack-1.14.0 → agstack-1.16.0}/agstack/security/casbin.py +0 -0
  58. {agstack-1.14.0 → agstack-1.16.0}/agstack/security/crypt.py +0 -0
  59. {agstack-1.14.0 → agstack-1.16.0}/agstack/status.py +0 -0
  60. {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/SOURCES.txt +0 -0
  61. {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/dependency_links.txt +0 -0
  62. {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/requires.txt +0 -0
  63. {agstack-1.14.0 → agstack-1.16.0}/agstack.egg-info/top_level.txt +0 -0
  64. {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.14.0
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 _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
 
@@ -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, "done")
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
- context.set_output(current_node_id, {"choice": "done"})
211
- current_node_id = self._resolve_next_node(current_node_id, "done")
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, "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")
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
- 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
- )
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, "done")
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
- yield event.step_started(step_name=f"parallel:{current_node_id}")
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
- context.set_output(current_node_id, {"choice": "done"})
359
- yield event.step_finished(step_name=f"parallel:{current_node_id}")
360
- current_node_id = self._resolve_next_node(current_node_id, "done")
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
- yield event.step_started(step_name=f"iteration:{current_node_id}")
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
- 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")
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
- 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
- )
494
+ current_node_id = self._resolve_next_node(current_node_id, context)
433
495
 
434
496
  else:
435
497
  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
  )
@@ -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 = {k: v for k, v in config.items() if k not in self._NODE_KEYS}
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
- return await ag.run(context, inputs=resolved)
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
- result = context.get_last_output(ag.name) or ""
50
- context.set_output(node_id, {"result": result})
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.14.0
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agstack"
3
- version = "1.14.0"
3
+ version = "1.16.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