agstack 1.22.1__tar.gz → 1.24.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 (79) hide show
  1. {agstack-1.22.1 → agstack-1.24.0}/PKG-INFO +1 -1
  2. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/flow.py +63 -7
  3. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/__init__.py +2 -0
  4. agstack-1.24.0/agstack/llm/flow/nodes/iterator_node.py +160 -0
  5. {agstack-1.22.1 → agstack-1.24.0}/agstack.egg-info/PKG-INFO +1 -1
  6. {agstack-1.22.1 → agstack-1.24.0}/agstack.egg-info/SOURCES.txt +2 -0
  7. {agstack-1.22.1 → agstack-1.24.0}/pyproject.toml +1 -1
  8. agstack-1.24.0/tests/test_flow_iterator.py +769 -0
  9. {agstack-1.22.1 → agstack-1.24.0}/LICENSE +0 -0
  10. {agstack-1.22.1 → agstack-1.24.0}/README.md +0 -0
  11. {agstack-1.22.1 → agstack-1.24.0}/agstack/__init__.py +0 -0
  12. {agstack-1.22.1 → agstack-1.24.0}/agstack/cache/__init__.py +0 -0
  13. {agstack-1.22.1 → agstack-1.24.0}/agstack/cache/base.py +0 -0
  14. {agstack-1.22.1 → agstack-1.24.0}/agstack/cache/memory.py +0 -0
  15. {agstack-1.22.1 → agstack-1.24.0}/agstack/cache/redis.py +0 -0
  16. {agstack-1.22.1 → agstack-1.24.0}/agstack/config/__init__.py +0 -0
  17. {agstack-1.22.1 → agstack-1.24.0}/agstack/config/logger.py +0 -0
  18. {agstack-1.22.1 → agstack-1.24.0}/agstack/config/manager.py +0 -0
  19. {agstack-1.22.1 → agstack-1.24.0}/agstack/config/types.py +0 -0
  20. {agstack-1.22.1 → agstack-1.24.0}/agstack/contexts.py +0 -0
  21. {agstack-1.22.1 → agstack-1.24.0}/agstack/decorators.py +0 -0
  22. {agstack-1.22.1 → agstack-1.24.0}/agstack/events.py +0 -0
  23. {agstack-1.22.1 → agstack-1.24.0}/agstack/exceptions.py +0 -0
  24. {agstack-1.22.1 → agstack-1.24.0}/agstack/fastapi/__init__.py +0 -0
  25. {agstack-1.22.1 → agstack-1.24.0}/agstack/fastapi/exception.py +0 -0
  26. {agstack-1.22.1 → agstack-1.24.0}/agstack/fastapi/middleware.py +0 -0
  27. {agstack-1.22.1 → agstack-1.24.0}/agstack/fastapi/offline.py +0 -0
  28. {agstack-1.22.1 → agstack-1.24.0}/agstack/fastapi/sse.py +0 -0
  29. {agstack-1.22.1 → agstack-1.24.0}/agstack/infra/db/__init__.py +0 -0
  30. {agstack-1.22.1 → agstack-1.24.0}/agstack/infra/es/__init__.py +0 -0
  31. {agstack-1.22.1 → agstack-1.24.0}/agstack/infra/kg/__init__.py +0 -0
  32. {agstack-1.22.1 → agstack-1.24.0}/agstack/infra/mq/__init__.py +0 -0
  33. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/__init__.py +0 -0
  34. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/client.py +0 -0
  35. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/__init__.py +0 -0
  36. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/agent.py +0 -0
  37. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/context.py +0 -0
  38. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/event.py +0 -0
  39. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/exceptions.py +0 -0
  40. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/factory.py +0 -0
  41. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/loader.py +0 -0
  42. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/agent_node.py +0 -0
  43. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/base.py +0 -0
  44. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
  45. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
  46. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
  47. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
  48. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
  49. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/python_node.py +0 -0
  50. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
  51. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
  52. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
  53. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/records.py +0 -0
  54. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/registry.py +0 -0
  55. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/sandbox.py +0 -0
  56. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/state.py +0 -0
  57. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/tool.py +0 -0
  58. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/flow/trace.py +0 -0
  59. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/prompts.py +0 -0
  60. {agstack-1.22.1 → agstack-1.24.0}/agstack/llm/token.py +0 -0
  61. {agstack-1.22.1 → agstack-1.24.0}/agstack/messagebus/__init__.py +0 -0
  62. {agstack-1.22.1 → agstack-1.24.0}/agstack/messagebus/base.py +0 -0
  63. {agstack-1.22.1 → agstack-1.24.0}/agstack/messagebus/memory.py +0 -0
  64. {agstack-1.22.1 → agstack-1.24.0}/agstack/messagebus/redis.py +0 -0
  65. {agstack-1.22.1 → agstack-1.24.0}/agstack/schema.py +0 -0
  66. {agstack-1.22.1 → agstack-1.24.0}/agstack/security/__init__.py +0 -0
  67. {agstack-1.22.1 → agstack-1.24.0}/agstack/security/casbin.py +0 -0
  68. {agstack-1.22.1 → agstack-1.24.0}/agstack/security/crypt.py +0 -0
  69. {agstack-1.22.1 → agstack-1.24.0}/agstack/status.py +0 -0
  70. {agstack-1.22.1 → agstack-1.24.0}/agstack.egg-info/dependency_links.txt +0 -0
  71. {agstack-1.22.1 → agstack-1.24.0}/agstack.egg-info/requires.txt +0 -0
  72. {agstack-1.22.1 → agstack-1.24.0}/agstack.egg-info/top_level.txt +0 -0
  73. {agstack-1.22.1 → agstack-1.24.0}/setup.cfg +0 -0
  74. {agstack-1.22.1 → agstack-1.24.0}/tests/test_cache_memory.py +0 -0
  75. {agstack-1.22.1 → agstack-1.24.0}/tests/test_cache_redis.py +0 -0
  76. {agstack-1.22.1 → agstack-1.24.0}/tests/test_flow_io.py +0 -0
  77. {agstack-1.22.1 → agstack-1.24.0}/tests/test_flow_switch_subflow.py +0 -0
  78. {agstack-1.22.1 → agstack-1.24.0}/tests/test_messagebus_memory.py +0 -0
  79. {agstack-1.22.1 → agstack-1.24.0}/tests/test_messagebus_redis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.22.1
3
+ Version: 1.24.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>
@@ -358,8 +358,19 @@ class Flow:
358
358
 
359
359
  elif node_type in self._node_handlers:
360
360
  handler = self._node_handlers[node_type]
361
- result = await handler.execute(node, context)
361
+ try:
362
+ result = await handler.execute(node, context)
363
+ except Exception as e:
364
+ iter_target = self._find_iterator_fallback(current_node_id)
365
+ if iter_target:
366
+ context.set_variable(f"_iter_{iter_target}_error", str(e))
367
+ context.set_output(current_node_id, {"error": str(e)})
368
+ context.set_variable("_prev_node_id", current_node_id)
369
+ current_node_id = iter_target
370
+ continue
371
+ raise
362
372
  context.set_output(current_node_id, result)
373
+ context.set_variable("_prev_node_id", current_node_id)
363
374
  current_node_id = self._resolve_next_node(current_node_id, context)
364
375
 
365
376
  else:
@@ -409,6 +420,18 @@ class Flow:
409
420
  )
410
421
  raise NodeExecutionError("UNKNOWN_NODE_TYPE", args={"node_type": node_type})
411
422
 
423
+ def _find_iterator_fallback(self, source_id: str) -> str | None:
424
+ """查找从 source_id 出发指向 iterator 节点的 edge target"""
425
+ for edge in self.edges:
426
+ if edge.get("source") == source_id:
427
+ target_id: str | None = edge.get("target")
428
+ if not target_id:
429
+ continue
430
+ target_node = self.get_node_config(target_id)
431
+ if target_node and target_node.get("type") == "iterator":
432
+ return target_id
433
+ return None
434
+
412
435
  async def _stream_edge_driven(self, context: "FlowContext") -> AsyncIterator[dict[str, Any]]:
413
436
  """边驱动流式执行"""
414
437
  current_node_id: str | None = self.nodes[0]["id"] if self.nodes else None
@@ -478,9 +501,9 @@ class Flow:
478
501
  step_evt["_echo"] = False
479
502
  yield step_evt
480
503
 
481
- parallel_qid = context.trace._qualify_id(current_node_id)
504
+ _pqid: str = context.trace._qualify_id(current_node_id)
482
505
 
483
- async def _exec_branch(branch_id: str, _parent_qid: str = parallel_qid) -> None:
506
+ async def _exec_branch(branch_id: str, parent_qid: str) -> None:
484
507
  branch_node = self.get_node_config(branch_id)
485
508
  if not branch_node:
486
509
  return
@@ -494,7 +517,7 @@ class Flow:
494
517
  branch_id,
495
518
  branch_type,
496
519
  inputs=branch_config.get("inputs", {}),
497
- parent_id=_parent_qid,
520
+ parent_id=parent_qid,
498
521
  )
499
522
  context.current_node = branch_id
500
523
  try:
@@ -505,7 +528,7 @@ class Flow:
505
528
  context.trace.record_node_end(branch_id, error=str(e))
506
529
  raise
507
530
 
508
- await asyncio.gather(*[_exec_branch(bid) for bid in branches])
531
+ await asyncio.gather(*[_exec_branch(bid, _pqid) for bid in branches])
509
532
  merged: dict[str, Any] = {}
510
533
  for bid in branches:
511
534
  branch_result = context.outputs.get(bid, {})
@@ -600,8 +623,32 @@ class Flow:
600
623
  label=config.get("label"),
601
624
  )
602
625
 
603
- async for evt in self._execute_node_with_retry(node, context, current_node_id):
604
- yield evt
626
+ # output_mode: "append" — 保存执行前的历史
627
+ append_mode = config.get("output_mode") == "append"
628
+ _history: list[Any] = []
629
+ if append_mode:
630
+ _prev_output = context.outputs.get(current_node_id)
631
+ _history = list(_prev_output) if isinstance(_prev_output, list) else []
632
+
633
+ try:
634
+ async for evt in self._execute_node_with_retry(node, context, current_node_id):
635
+ yield evt
636
+ except (NodeExecutionError, Exception) as e:
637
+ # 迭代体错误容忍:如果该节点有 edge 指向 iterator,路由回去而非中断 flow
638
+ iter_target = self._find_iterator_fallback(current_node_id)
639
+ if iter_target:
640
+ context.set_variable(f"_iter_{iter_target}_error", str(e))
641
+ context.set_output(current_node_id, {"error": str(e)})
642
+ context.trace.record_node_end(current_node_id, error=str(e))
643
+ context.set_variable("_prev_node_id", current_node_id)
644
+ current_node_id = iter_target
645
+ continue
646
+ raise
647
+
648
+ # output_mode: "append" — 合并新输出到历史数组
649
+ if append_mode:
650
+ _history.append(context.outputs.get(current_node_id))
651
+ context.outputs[current_node_id] = _history
605
652
 
606
653
  # 收集 agent 节点存放的 tool_calls 或通用 execution_records
607
654
  tool_calls = context.get_variable("_last_node_tool_calls")
@@ -622,6 +669,15 @@ class Flow:
622
669
  tool_calls=tool_calls if tool_calls else None,
623
670
  messages=messages,
624
671
  )
672
+
673
+ # Iterator state cleanup: done 后清理 state 以支持 external re-entry
674
+ if node_type == "iterator":
675
+ _iter_out = context.outputs.get(current_node_id, {})
676
+ if isinstance(_iter_out, dict) and _iter_out.get("done"):
677
+ if not config.get("preserve_state", False):
678
+ context.set_variable(f"_iter_{current_node_id}", None)
679
+
680
+ context.set_variable("_prev_node_id", current_node_id)
625
681
  current_node_id = self._resolve_next_node(current_node_id, context)
626
682
 
627
683
  else:
@@ -6,6 +6,7 @@ from .agent_node import AgentNodeHandler
6
6
  from .base import NodeHandler
7
7
  from .detect_node import DetectNodeHandler
8
8
  from .echo_node import EchoNodeHandler
9
+ from .iterator_node import IteratorNodeHandler
9
10
  from .llm_chat_node import LLMChatNodeHandler
10
11
  from .llm_embed_node import LLMEmbedNodeHandler
11
12
  from .llm_rerank_node import LLMRerankNodeHandler
@@ -27,6 +28,7 @@ builtin_handlers: list[NodeHandler] = [
27
28
  EchoNodeHandler(),
28
29
  SwitchNodeHandler(),
29
30
  SubflowNodeHandler(),
31
+ IteratorNodeHandler(),
30
32
  ]
31
33
 
32
34
  __all__ = [
@@ -0,0 +1,160 @@
1
+ # Copyright (c) 2020-2026 XtraVisions, All rights reserved.
2
+
3
+ """Iterator 节点处理器 — 通用迭代原语,管理遍历状态并通过 edge 组合实现路由"""
4
+
5
+ from typing import TYPE_CHECKING, Any, AsyncIterator
6
+ from uuid import uuid4
7
+
8
+ from .. import event
9
+ from .base import NodeHandler
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from ..context import FlowContext
14
+
15
+
16
+ class IteratorNodeHandler(NodeHandler):
17
+ """Iterator 节点:管理数组遍历状态,暴露当前元素供下游引用。
18
+
19
+ 通过 edge-driven 执行被多次访问:
20
+ - 首次访问:初始化迭代,暴露第一个元素,走迭代体 edge
21
+ - Cycle-back:推进 index,继续或走 completion edge
22
+ """
23
+
24
+ node_type = "iterator"
25
+
26
+ def _resolve_items(self, config: dict, context: "FlowContext") -> list:
27
+ items_ref = config.get("items", "")
28
+ items = context.resolve_reference(items_ref) if isinstance(items_ref, str) else items_ref
29
+ if items is None:
30
+ return []
31
+ if not isinstance(items, list):
32
+ return [items]
33
+ return items
34
+
35
+ def _set_item_output(self, node_id: str, state: dict, config: dict, context: "FlowContext") -> None:
36
+ idx = state["index"]
37
+ context.set_output(
38
+ node_id,
39
+ {
40
+ "current_item": state["items"][idx],
41
+ "index": idx,
42
+ "count": len(state["items"]),
43
+ "done": False,
44
+ },
45
+ )
46
+
47
+ def _set_done_output(self, node_id: str, state: dict, config: dict, context: "FlowContext") -> None:
48
+ context.set_output(
49
+ node_id,
50
+ {
51
+ "done": True,
52
+ "count": len(state["items"]),
53
+ },
54
+ )
55
+
56
+ def _build_event(
57
+ self, config: dict, event_key: str, state: dict, *, error: str | None = None
58
+ ) -> dict[str, Any] | None:
59
+ events_config = config.get("events")
60
+ if not events_config:
61
+ return None
62
+ evt_cfg = events_config.get(event_key)
63
+ if not evt_cfg:
64
+ return None
65
+
66
+ name = evt_cfg.get("name", event_key)
67
+ value_template = evt_cfg.get("value", {})
68
+ value = self._interpolate_event_value(value_template, state, error=error)
69
+ return event.custom(name=name, value=value)
70
+
71
+ def _interpolate_event_value(self, template: Any, state: dict, *, error: str | None = None) -> Any:
72
+ if isinstance(template, str):
73
+ if template == "$items":
74
+ return state["items"]
75
+ if template == "$index":
76
+ return state["index"]
77
+ if template == "$item":
78
+ return state["items"][state["index"]] if state["index"] < len(state["items"]) else None
79
+ if template == "$count":
80
+ return len(state["items"])
81
+ if template == "$error":
82
+ return error
83
+ return template
84
+ if isinstance(template, dict):
85
+ return {k: self._interpolate_event_value(v, state, error=error) for k, v in template.items()}
86
+ if isinstance(template, list):
87
+ return [self._interpolate_event_value(v, state, error=error) for v in template]
88
+ return template
89
+
90
+ async def execute(self, node: dict, context: "FlowContext") -> Any:
91
+ config = node.get("config", {})
92
+ items = self._resolve_items(config, context)
93
+ return {"done": True, "count": len(items), "current_item": None, "index": 0}
94
+
95
+ async def stream(self, node: dict, context: "FlowContext", node_id: str) -> AsyncIterator[dict[str, Any]]:
96
+ config = node.get("config", {})
97
+ state_key = f"_iter_{node_id}"
98
+ step_name = self.get_step_name(node, node_id)
99
+
100
+ state = context.get_variable(state_key)
101
+
102
+ if state is None:
103
+ # ═══ 首次访问(或 re-entry 后重新初始化)═══
104
+ items = self._resolve_items(config, context)
105
+ state = {"items": items, "index": 0}
106
+ context.set_variable(state_key, state)
107
+
108
+ sid = str(uuid4())
109
+ yield event.step_started(step_name=step_name, step_id=sid)
110
+
111
+ evt = self._build_event(config, "on_start", state)
112
+ if evt:
113
+ yield evt
114
+
115
+ if not items:
116
+ self._set_done_output(node_id, state, config, context)
117
+ yield event.step_finished(step_name=step_name, step_id=sid)
118
+ return
119
+
120
+ self._set_item_output(node_id, state, config, context)
121
+ evt = self._build_event(config, "on_item_start", state)
122
+ if evt:
123
+ yield evt
124
+
125
+ yield event.step_finished(step_name=step_name, step_id=sid)
126
+
127
+ else:
128
+ # ═══ Cycle-back:推进 index ═══
129
+ sid = str(uuid4())
130
+ yield event.step_started(step_name=step_name, step_id=sid)
131
+
132
+ error = context.get_variable(f"_iter_{node_id}_error")
133
+
134
+ if error:
135
+ evt = self._build_event(config, "on_item_error", state, error=error)
136
+ if evt:
137
+ yield evt
138
+ context.set_variable(f"_iter_{node_id}_error", None)
139
+ else:
140
+ evt = self._build_event(config, "on_item_end", state)
141
+ if evt:
142
+ yield evt
143
+
144
+ state["index"] += 1
145
+ max_iter = config.get("max_iterations")
146
+
147
+ if state["index"] >= len(state["items"]) or (max_iter and state["index"] >= max_iter):
148
+ self._set_done_output(node_id, state, config, context)
149
+ yield event.step_finished(step_name=step_name, step_id=sid)
150
+ return
151
+
152
+ self._set_item_output(node_id, state, config, context)
153
+ evt = self._build_event(config, "on_item_start", state)
154
+ if evt:
155
+ yield evt
156
+
157
+ yield event.step_finished(step_name=step_name, step_id=sid)
158
+
159
+ def get_step_name(self, node: dict, node_id: str) -> str:
160
+ return f"iterator:{node_id}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.22.1
3
+ Version: 1.24.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>
@@ -53,6 +53,7 @@ agstack/llm/flow/nodes/agent_node.py
53
53
  agstack/llm/flow/nodes/base.py
54
54
  agstack/llm/flow/nodes/detect_node.py
55
55
  agstack/llm/flow/nodes/echo_node.py
56
+ agstack/llm/flow/nodes/iterator_node.py
56
57
  agstack/llm/flow/nodes/llm_chat_node.py
57
58
  agstack/llm/flow/nodes/llm_embed_node.py
58
59
  agstack/llm/flow/nodes/llm_rerank_node.py
@@ -70,6 +71,7 @@ agstack/security/crypt.py
70
71
  tests/test_cache_memory.py
71
72
  tests/test_cache_redis.py
72
73
  tests/test_flow_io.py
74
+ tests/test_flow_iterator.py
73
75
  tests/test_flow_switch_subflow.py
74
76
  tests/test_messagebus_memory.py
75
77
  tests/test_messagebus_redis.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agstack"
3
- version = "1.22.1"
3
+ version = "1.24.0"
4
4
  description = "Production-ready toolkit for building FastAPI and LLM applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"