agstack 1.23.0__tar.gz → 1.24.1__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.23.0 → agstack-1.24.1}/PKG-INFO +1 -1
  2. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/agent.py +3 -1
  3. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/flow.py +20 -0
  4. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/iterator_node.py +8 -20
  5. {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/PKG-INFO +1 -1
  6. {agstack-1.23.0 → agstack-1.24.1}/pyproject.toml +1 -1
  7. {agstack-1.23.0 → agstack-1.24.1}/tests/test_flow_iterator.py +302 -34
  8. {agstack-1.23.0 → agstack-1.24.1}/LICENSE +0 -0
  9. {agstack-1.23.0 → agstack-1.24.1}/README.md +0 -0
  10. {agstack-1.23.0 → agstack-1.24.1}/agstack/__init__.py +0 -0
  11. {agstack-1.23.0 → agstack-1.24.1}/agstack/cache/__init__.py +0 -0
  12. {agstack-1.23.0 → agstack-1.24.1}/agstack/cache/base.py +0 -0
  13. {agstack-1.23.0 → agstack-1.24.1}/agstack/cache/memory.py +0 -0
  14. {agstack-1.23.0 → agstack-1.24.1}/agstack/cache/redis.py +0 -0
  15. {agstack-1.23.0 → agstack-1.24.1}/agstack/config/__init__.py +0 -0
  16. {agstack-1.23.0 → agstack-1.24.1}/agstack/config/logger.py +0 -0
  17. {agstack-1.23.0 → agstack-1.24.1}/agstack/config/manager.py +0 -0
  18. {agstack-1.23.0 → agstack-1.24.1}/agstack/config/types.py +0 -0
  19. {agstack-1.23.0 → agstack-1.24.1}/agstack/contexts.py +0 -0
  20. {agstack-1.23.0 → agstack-1.24.1}/agstack/decorators.py +0 -0
  21. {agstack-1.23.0 → agstack-1.24.1}/agstack/events.py +0 -0
  22. {agstack-1.23.0 → agstack-1.24.1}/agstack/exceptions.py +0 -0
  23. {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/__init__.py +0 -0
  24. {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/exception.py +0 -0
  25. {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/middleware.py +0 -0
  26. {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/offline.py +0 -0
  27. {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/sse.py +0 -0
  28. {agstack-1.23.0 → agstack-1.24.1}/agstack/infra/db/__init__.py +0 -0
  29. {agstack-1.23.0 → agstack-1.24.1}/agstack/infra/es/__init__.py +0 -0
  30. {agstack-1.23.0 → agstack-1.24.1}/agstack/infra/kg/__init__.py +0 -0
  31. {agstack-1.23.0 → agstack-1.24.1}/agstack/infra/mq/__init__.py +0 -0
  32. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/__init__.py +0 -0
  33. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/client.py +0 -0
  34. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/__init__.py +0 -0
  35. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/context.py +0 -0
  36. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/event.py +0 -0
  37. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/exceptions.py +0 -0
  38. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/factory.py +0 -0
  39. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/loader.py +0 -0
  40. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/__init__.py +0 -0
  41. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/agent_node.py +0 -0
  42. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/base.py +0 -0
  43. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/detect_node.py +0 -0
  44. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/echo_node.py +0 -0
  45. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
  46. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
  47. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
  48. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/python_node.py +0 -0
  49. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/subflow_node.py +0 -0
  50. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/switch_node.py +0 -0
  51. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/tool_node.py +0 -0
  52. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/records.py +0 -0
  53. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/registry.py +0 -0
  54. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/sandbox.py +0 -0
  55. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/state.py +0 -0
  56. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/tool.py +0 -0
  57. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/trace.py +0 -0
  58. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/prompts.py +0 -0
  59. {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/token.py +0 -0
  60. {agstack-1.23.0 → agstack-1.24.1}/agstack/messagebus/__init__.py +0 -0
  61. {agstack-1.23.0 → agstack-1.24.1}/agstack/messagebus/base.py +0 -0
  62. {agstack-1.23.0 → agstack-1.24.1}/agstack/messagebus/memory.py +0 -0
  63. {agstack-1.23.0 → agstack-1.24.1}/agstack/messagebus/redis.py +0 -0
  64. {agstack-1.23.0 → agstack-1.24.1}/agstack/schema.py +0 -0
  65. {agstack-1.23.0 → agstack-1.24.1}/agstack/security/__init__.py +0 -0
  66. {agstack-1.23.0 → agstack-1.24.1}/agstack/security/casbin.py +0 -0
  67. {agstack-1.23.0 → agstack-1.24.1}/agstack/security/crypt.py +0 -0
  68. {agstack-1.23.0 → agstack-1.24.1}/agstack/status.py +0 -0
  69. {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/SOURCES.txt +0 -0
  70. {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/dependency_links.txt +0 -0
  71. {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/requires.txt +0 -0
  72. {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/top_level.txt +0 -0
  73. {agstack-1.23.0 → agstack-1.24.1}/setup.cfg +0 -0
  74. {agstack-1.23.0 → agstack-1.24.1}/tests/test_cache_memory.py +0 -0
  75. {agstack-1.23.0 → agstack-1.24.1}/tests/test_cache_redis.py +0 -0
  76. {agstack-1.23.0 → agstack-1.24.1}/tests/test_flow_io.py +0 -0
  77. {agstack-1.23.0 → agstack-1.24.1}/tests/test_flow_switch_subflow.py +0 -0
  78. {agstack-1.23.0 → agstack-1.24.1}/tests/test_messagebus_memory.py +0 -0
  79. {agstack-1.23.0 → agstack-1.24.1}/tests/test_messagebus_redis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.23.0
3
+ Version: 1.24.1
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>
@@ -31,6 +31,7 @@ class Agent:
31
31
  max_tokens: int | None = None,
32
32
  max_turns: int = 10,
33
33
  *,
34
+ tool_choice: str = "auto",
34
35
  label: str | None = None,
35
36
  echo: bool = False,
36
37
  ):
@@ -53,6 +54,7 @@ class Agent:
53
54
  self.temperature = temperature
54
55
  self.max_tokens = max_tokens
55
56
  self.max_turns = max_turns
57
+ self.tool_choice = tool_choice
56
58
  self.label = label
57
59
  self.echo = echo
58
60
 
@@ -135,7 +137,7 @@ class Agent:
135
137
 
136
138
  if tools_schema:
137
139
  kwargs["tools"] = tools_schema
138
- kwargs["tool_choice"] = "auto"
140
+ kwargs["tool_choice"] = self.tool_choice
139
141
 
140
142
  stream = await client.chat(stream=True, **kwargs)
141
143
 
@@ -623,6 +623,13 @@ class Flow:
623
623
  label=config.get("label"),
624
624
  )
625
625
 
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
+
626
633
  try:
627
634
  async for evt in self._execute_node_with_retry(node, context, current_node_id):
628
635
  yield evt
@@ -638,6 +645,11 @@ class Flow:
638
645
  continue
639
646
  raise
640
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
652
+
641
653
  # 收集 agent 节点存放的 tool_calls 或通用 execution_records
642
654
  tool_calls = context.get_variable("_last_node_tool_calls")
643
655
  if tool_calls is None:
@@ -657,6 +669,14 @@ class Flow:
657
669
  tool_calls=tool_calls if tool_calls else None,
658
670
  messages=messages,
659
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
+
660
680
  context.set_variable("_prev_node_id", current_node_id)
661
681
  current_node_id = self._resolve_next_node(current_node_id, context)
662
682
 
@@ -14,11 +14,11 @@ if TYPE_CHECKING:
14
14
 
15
15
 
16
16
  class IteratorNodeHandler(NodeHandler):
17
- """Iterator 节点:管理数组遍历状态,暴露当前元素供下游引用,收集每轮结果。
17
+ """Iterator 节点:管理数组遍历状态,暴露当前元素供下游引用。
18
18
 
19
19
  通过 edge-driven 执行被多次访问:
20
20
  - 首次访问:初始化迭代,暴露第一个元素,走迭代体 edge
21
- - Cycle-back:收集上一轮结果,推进 index,继续或走 completion edge
21
+ - Cycle-back:推进 index,继续或走 completion edge
22
22
  """
23
23
 
24
24
  node_type = "iterator"
@@ -33,7 +33,6 @@ class IteratorNodeHandler(NodeHandler):
33
33
  return items
34
34
 
35
35
  def _set_item_output(self, node_id: str, state: dict, config: dict, context: "FlowContext") -> None:
36
- collect_to = config.get("collect_to", "results")
37
36
  idx = state["index"]
38
37
  context.set_output(
39
38
  node_id,
@@ -42,18 +41,15 @@ class IteratorNodeHandler(NodeHandler):
42
41
  "index": idx,
43
42
  "count": len(state["items"]),
44
43
  "done": False,
45
- collect_to: list(state["collected"]),
46
44
  },
47
45
  )
48
46
 
49
47
  def _set_done_output(self, node_id: str, state: dict, config: dict, context: "FlowContext") -> None:
50
- collect_to = config.get("collect_to", "results")
51
48
  context.set_output(
52
49
  node_id,
53
50
  {
54
51
  "done": True,
55
52
  "count": len(state["items"]),
56
- collect_to: list(state["collected"]),
57
53
  },
58
54
  )
59
55
 
@@ -74,6 +70,8 @@ class IteratorNodeHandler(NodeHandler):
74
70
 
75
71
  def _interpolate_event_value(self, template: Any, state: dict, *, error: str | None = None) -> Any:
76
72
  if isinstance(template, str):
73
+ if template == "$items":
74
+ return state["items"]
77
75
  if template == "$index":
78
76
  return state["index"]
79
77
  if template == "$item":
@@ -92,8 +90,7 @@ class IteratorNodeHandler(NodeHandler):
92
90
  async def execute(self, node: dict, context: "FlowContext") -> Any:
93
91
  config = node.get("config", {})
94
92
  items = self._resolve_items(config, context)
95
- collect_to = config.get("collect_to", "results")
96
- return {"done": True, "count": len(items), collect_to: [], "current_item": None, "index": 0}
93
+ return {"done": True, "count": len(items), "current_item": None, "index": 0}
97
94
 
98
95
  async def stream(self, node: dict, context: "FlowContext", node_id: str) -> AsyncIterator[dict[str, Any]]:
99
96
  config = node.get("config", {})
@@ -103,9 +100,9 @@ class IteratorNodeHandler(NodeHandler):
103
100
  state = context.get_variable(state_key)
104
101
 
105
102
  if state is None:
106
- # ═══ 首次访问:初始化 ═══
103
+ # ═══ 首次访问(或 re-entry 后重新初始化)═══
107
104
  items = self._resolve_items(config, context)
108
- state = {"items": items, "index": 0, "collected": []}
105
+ state = {"items": items, "index": 0}
109
106
  context.set_variable(state_key, state)
110
107
 
111
108
  sid = str(uuid4())
@@ -128,27 +125,18 @@ class IteratorNodeHandler(NodeHandler):
128
125
  yield event.step_finished(step_name=step_name, step_id=sid)
129
126
 
130
127
  else:
131
- # ═══ Cycle-back:收集 + 推进 ═══
128
+ # ═══ Cycle-back:推进 index ═══
132
129
  sid = str(uuid4())
133
130
  yield event.step_started(step_name=step_name, step_id=sid)
134
131
 
135
- prev_node_id = context.get_variable("_prev_node_id")
136
132
  error = context.get_variable(f"_iter_{node_id}_error")
137
133
 
138
134
  if error:
139
- state["collected"].append(
140
- {
141
- "error": error,
142
- "item": state["items"][state["index"]] if state["index"] < len(state["items"]) else None,
143
- }
144
- )
145
135
  evt = self._build_event(config, "on_item_error", state, error=error)
146
136
  if evt:
147
137
  yield evt
148
138
  context.set_variable(f"_iter_{node_id}_error", None)
149
139
  else:
150
- prev_output = context.outputs.get(prev_node_id) if prev_node_id else None
151
- state["collected"].append(prev_output)
152
140
  evt = self._build_event(config, "on_item_end", state)
153
141
  if evt:
154
142
  yield evt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 1.23.0
3
+ Version: 1.24.1
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.23.0"
3
+ version = "1.24.1"
4
4
  description = "Production-ready toolkit for building FastAPI and LLM applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -81,23 +81,21 @@ class TestIteratorNodeHandler:
81
81
 
82
82
  def test_set_item_output(self):
83
83
  ctx = FlowContext()
84
- state = {"items": ["a", "b", "c"], "index": 1, "collected": ["prev"]}
85
- self.handler._set_item_output("loop", state, {"collect_to": "results"}, ctx)
84
+ state = {"items": ["a", "b", "c"], "index": 1}
85
+ self.handler._set_item_output("loop", state, {}, ctx)
86
86
  output = ctx.outputs["loop"]
87
87
  assert output["current_item"] == "b"
88
88
  assert output["index"] == 1
89
89
  assert output["count"] == 3
90
90
  assert output["done"] is False
91
- assert output["results"] == ["prev"]
92
91
 
93
92
  def test_set_done_output(self):
94
93
  ctx = FlowContext()
95
- state = {"items": ["a", "b"], "index": 2, "collected": ["r1", "r2"]}
96
- self.handler._set_done_output("loop", state, {"collect_to": "results"}, ctx)
94
+ state = {"items": ["a", "b"], "index": 2}
95
+ self.handler._set_done_output("loop", state, {}, ctx)
97
96
  output = ctx.outputs["loop"]
98
97
  assert output["done"] is True
99
98
  assert output["count"] == 2
100
- assert output["results"] == ["r1", "r2"]
101
99
 
102
100
  def test_build_event_with_interpolation(self):
103
101
  config = {
@@ -108,7 +106,7 @@ class TestIteratorNodeHandler:
108
106
  }
109
107
  }
110
108
  }
111
- state = {"items": ["x", "y", "z"], "index": 0, "collected": []}
109
+ state = {"items": ["x", "y", "z"], "index": 0}
112
110
  evt = self.handler._build_event(config, "on_start", state)
113
111
  assert evt is not None
114
112
  assert evt["type"] == "CUSTOM"
@@ -117,7 +115,7 @@ class TestIteratorNodeHandler:
117
115
  assert evt["value"]["first"] == "x"
118
116
 
119
117
  def test_build_event_missing_config_returns_none(self):
120
- evt = self.handler._build_event({}, "on_start", {"items": [], "index": 0, "collected": []})
118
+ evt = self.handler._build_event({}, "on_start", {"items": [], "index": 0})
121
119
  assert evt is None
122
120
 
123
121
  def test_execute_returns_done(self):
@@ -136,7 +134,7 @@ class TestIteratorFlowIntegration:
136
134
  """iterator 节点 + edge-driven flow 集成测试"""
137
135
 
138
136
  def test_basic_iteration(self):
139
- """遍历 3 个 items,每轮调用 echo tool,收集结果"""
137
+ """遍历 3 个 items,每轮调用 echo tool,通过 output_mode=append 收集结果"""
140
138
  flow = Flow(
141
139
  flow_id="test",
142
140
  name="iter_test",
@@ -144,10 +142,7 @@ class TestIteratorFlowIntegration:
144
142
  {
145
143
  "id": "loop",
146
144
  "type": "iterator",
147
- "config": {
148
- "items": "$v.tasks",
149
- "collect_to": "results",
150
- },
145
+ "config": {"items": "$v.tasks"},
151
146
  },
152
147
  {
153
148
  "id": "do_work",
@@ -155,6 +150,7 @@ class TestIteratorFlowIntegration:
155
150
  "config": {
156
151
  "tool_name": "test_echo",
157
152
  "inputs": {"query": "$o.loop.current_item"},
153
+ "output_mode": "append",
158
154
  },
159
155
  },
160
156
  {
@@ -178,10 +174,12 @@ class TestIteratorFlowIntegration:
178
174
  loop_output = ctx.outputs["loop"]
179
175
  assert loop_output["done"] is True
180
176
  assert loop_output["count"] == 3
181
- assert len(loop_output["results"]) == 3
182
177
 
183
- # 验证每轮结果是 echo tool 的输出
184
- for i, result in enumerate(loop_output["results"]):
178
+ # 验证每轮结果通过 append 累积
179
+ work_output = ctx.outputs["do_work"]
180
+ assert isinstance(work_output, list)
181
+ assert len(work_output) == 3
182
+ for i, result in enumerate(work_output):
185
183
  assert result == {"echo": {"query": f"task_{chr(ord('a') + i)}"}}
186
184
 
187
185
  def test_empty_items_goes_to_completion(self):
@@ -193,7 +191,7 @@ class TestIteratorFlowIntegration:
193
191
  {
194
192
  "id": "loop",
195
193
  "type": "iterator",
196
- "config": {"items": "$v.tasks", "collect_to": "results"},
194
+ "config": {"items": "$v.tasks"},
197
195
  },
198
196
  {"id": "body", "type": "tool", "config": {"tool_name": "test_echo", "inputs": {}}},
199
197
  {
@@ -213,7 +211,7 @@ class TestIteratorFlowIntegration:
213
211
 
214
212
  loop_output = ctx.outputs["loop"]
215
213
  assert loop_output["done"] is True
216
- assert loop_output["results"] == []
214
+ assert loop_output["count"] == 0
217
215
  # end 节点应该被执行
218
216
  assert "end" in ctx.outputs
219
217
 
@@ -228,14 +226,17 @@ class TestIteratorFlowIntegration:
228
226
  "type": "iterator",
229
227
  "config": {
230
228
  "items": "$v.tasks",
231
- "collect_to": "results",
232
229
  "max_iterations": 2,
233
230
  },
234
231
  },
235
232
  {
236
233
  "id": "body",
237
234
  "type": "tool",
238
- "config": {"tool_name": "test_echo", "inputs": {"x": "$o.loop.current_item"}},
235
+ "config": {
236
+ "tool_name": "test_echo",
237
+ "inputs": {"x": "$o.loop.current_item"},
238
+ "output_mode": "append",
239
+ },
239
240
  },
240
241
  {
241
242
  "id": "end",
@@ -254,10 +255,10 @@ class TestIteratorFlowIntegration:
254
255
 
255
256
  loop_output = ctx.outputs["loop"]
256
257
  assert loop_output["done"] is True
257
- assert len(loop_output["results"]) == 2
258
+ assert len(ctx.outputs["body"]) == 2
258
259
 
259
260
  def test_error_tolerance(self):
260
- """单项失败不中断循环,记录 error 后继续"""
261
+ """单项失败不中断循环,继续下一项"""
261
262
  flow = Flow(
262
263
  flow_id="test",
263
264
  name="iter_error",
@@ -265,7 +266,7 @@ class TestIteratorFlowIntegration:
265
266
  {
266
267
  "id": "loop",
267
268
  "type": "iterator",
268
- "config": {"items": "$v.tasks", "collect_to": "results"},
269
+ "config": {"items": "$v.tasks"},
269
270
  },
270
271
  {
271
272
  "id": "body",
@@ -289,10 +290,7 @@ class TestIteratorFlowIntegration:
289
290
 
290
291
  loop_output = ctx.outputs["loop"]
291
292
  assert loop_output["done"] is True
292
- assert len(loop_output["results"]) == 2
293
- # 每个结果都应该包含 error
294
- for result in loop_output["results"]:
295
- assert "error" in result
293
+ assert loop_output["count"] == 2
296
294
 
297
295
  def test_custom_events_emitted(self):
298
296
  """验证 CUSTOM 事件发射"""
@@ -305,7 +303,6 @@ class TestIteratorFlowIntegration:
305
303
  "type": "iterator",
306
304
  "config": {
307
305
  "items": "$v.tasks",
308
- "collect_to": "results",
309
306
  "events": {
310
307
  "on_start": {"name": "loop_started", "value": {"total": "$count"}},
311
308
  "on_item_start": {"name": "item_begin", "value": {"idx": "$index"}},
@@ -366,7 +363,7 @@ class TestIteratorFlowIntegration:
366
363
  {
367
364
  "id": "loop",
368
365
  "type": "iterator",
369
- "config": {"items": "$v.tasks", "collect_to": "results"},
366
+ "config": {"items": "$v.tasks"},
370
367
  },
371
368
  {
372
369
  "id": "route",
@@ -382,6 +379,7 @@ class TestIteratorFlowIntegration:
382
379
  "config": {
383
380
  "tool_name": "test_upper",
384
381
  "inputs": {"text": "$o.loop.current_item.text"},
382
+ "output_mode": "append",
385
383
  },
386
384
  },
387
385
  {
@@ -390,6 +388,7 @@ class TestIteratorFlowIntegration:
390
388
  "config": {
391
389
  "tool_name": "test_lower",
392
390
  "inputs": {"text": "$o.loop.current_item.text"},
391
+ "output_mode": "append",
393
392
  },
394
393
  },
395
394
  {
@@ -420,11 +419,280 @@ class TestIteratorFlowIntegration:
420
419
 
421
420
  loop_output = ctx.outputs["loop"]
422
421
  assert loop_output["done"] is True
423
- assert loop_output["results"] == [
424
- {"result": "HELLO"},
425
- {"result": "world"},
426
- {"result": "FOO"},
427
- ]
422
+ assert ctx.outputs["do_upper"] == [{"result": "HELLO"}, {"result": "FOO"}]
423
+ assert ctx.outputs["do_lower"] == [{"result": "world"}]
424
+
425
+
426
+ # ── output_mode / state cleanup / re-entry 测试 ──
427
+
428
+
429
+ class TestOutputModeAppend:
430
+ """output_mode: "append" 节点输出累积"""
431
+
432
+ def test_append_accumulates_across_iterations(self):
433
+ """body 节点配置 output_mode=append,每轮结果追加到数组"""
434
+ flow = Flow(
435
+ flow_id="test",
436
+ name="append_test",
437
+ nodes=[
438
+ {
439
+ "id": "loop",
440
+ "type": "iterator",
441
+ "config": {"items": "$v.tasks"},
442
+ },
443
+ {
444
+ "id": "body",
445
+ "type": "tool",
446
+ "config": {
447
+ "tool_name": "test_echo",
448
+ "inputs": {"item": "$o.loop.current_item"},
449
+ "output_mode": "append",
450
+ },
451
+ },
452
+ {
453
+ "id": "end",
454
+ "type": "python",
455
+ "config": {"code": "def main():\n return {'status': 'done'}"},
456
+ },
457
+ ],
458
+ edges=[
459
+ {"source": "loop", "target": "body"},
460
+ {"source": "body", "target": "loop"},
461
+ {"source": "loop", "target": "end", "condition": "$o.loop.done"},
462
+ ],
463
+ )
464
+ ctx = FlowContext(variables={"tasks": ["a", "b", "c"]})
465
+ run(collect_events(flow, ctx))
466
+
467
+ # body output 是累积数组
468
+ body_output = ctx.outputs["body"]
469
+ assert isinstance(body_output, list)
470
+ assert len(body_output) == 3
471
+ assert body_output[0] == {"echo": {"item": "a"}}
472
+ assert body_output[1] == {"echo": {"item": "b"}}
473
+ assert body_output[2] == {"echo": {"item": "c"}}
474
+
475
+ def test_default_overwrite_unchanged(self):
476
+ """不配置 output_mode 时保持覆写行为"""
477
+ flow = Flow(
478
+ flow_id="test",
479
+ name="overwrite_test",
480
+ nodes=[
481
+ {
482
+ "id": "loop",
483
+ "type": "iterator",
484
+ "config": {"items": "$v.tasks"},
485
+ },
486
+ {
487
+ "id": "body",
488
+ "type": "tool",
489
+ "config": {"tool_name": "test_echo", "inputs": {"item": "$o.loop.current_item"}},
490
+ },
491
+ {
492
+ "id": "end",
493
+ "type": "python",
494
+ "config": {"code": "def main():\n return {'done': True}"},
495
+ },
496
+ ],
497
+ edges=[
498
+ {"source": "loop", "target": "body"},
499
+ {"source": "body", "target": "loop"},
500
+ {"source": "loop", "target": "end", "condition": "$o.loop.done"},
501
+ ],
502
+ )
503
+ ctx = FlowContext(variables={"tasks": ["x", "y"]})
504
+ run(collect_events(flow, ctx))
505
+
506
+ # 只保留最后一次的输出
507
+ body_output = ctx.outputs["body"]
508
+ assert body_output == {"echo": {"item": "y"}}
509
+
510
+
511
+ class TestIteratorStateCleanup:
512
+ """Iterator completion 后 state 清理"""
513
+
514
+ def test_state_cleared_on_done(self):
515
+ """迭代完成后 state 被清理为 None"""
516
+ flow = Flow(
517
+ flow_id="test",
518
+ name="cleanup_test",
519
+ nodes=[
520
+ {
521
+ "id": "loop",
522
+ "type": "iterator",
523
+ "config": {"items": "$v.tasks"},
524
+ },
525
+ {
526
+ "id": "body",
527
+ "type": "tool",
528
+ "config": {"tool_name": "test_echo", "inputs": {}},
529
+ },
530
+ {
531
+ "id": "end",
532
+ "type": "python",
533
+ "config": {"code": "def main():\n return {'ok': True}"},
534
+ },
535
+ ],
536
+ edges=[
537
+ {"source": "loop", "target": "body"},
538
+ {"source": "body", "target": "loop"},
539
+ {"source": "loop", "target": "end", "condition": "$o.loop.done"},
540
+ ],
541
+ )
542
+ ctx = FlowContext(variables={"tasks": ["a"]})
543
+ run(collect_events(flow, ctx))
544
+
545
+ assert ctx.get_variable("_iter_loop") is None
546
+
547
+ def test_preserve_state_option(self):
548
+ """preserve_state=true 时不清理"""
549
+ flow = Flow(
550
+ flow_id="test",
551
+ name="preserve_test",
552
+ nodes=[
553
+ {
554
+ "id": "loop",
555
+ "type": "iterator",
556
+ "config": {"items": "$v.tasks", "preserve_state": True},
557
+ },
558
+ {
559
+ "id": "body",
560
+ "type": "tool",
561
+ "config": {"tool_name": "test_echo", "inputs": {}},
562
+ },
563
+ {
564
+ "id": "end",
565
+ "type": "python",
566
+ "config": {"code": "def main():\n return {'ok': True}"},
567
+ },
568
+ ],
569
+ edges=[
570
+ {"source": "loop", "target": "body"},
571
+ {"source": "body", "target": "loop"},
572
+ {"source": "loop", "target": "end", "condition": "$o.loop.done"},
573
+ ],
574
+ )
575
+ ctx = FlowContext(variables={"tasks": ["a"]})
576
+ run(collect_events(flow, ctx))
577
+
578
+ state = ctx.get_variable("_iter_loop")
579
+ assert state is not None
580
+ assert state["index"] == 1
581
+
582
+
583
+ class TestIteratorReentry:
584
+ """External re-entry 场景(plan → iterate → replan → re-iterate)"""
585
+
586
+ def test_reentry_after_completion(self):
587
+ """Iterator 完成后被外部节点重新路由,正确重新初始化"""
588
+
589
+ # producer 模拟:第一次输出 [A, B],第二次输出 [C]
590
+ call_count = {"n": 0}
591
+
592
+ def producer_fn(context, inputs):
593
+ call_count["n"] += 1
594
+ if call_count["n"] == 1:
595
+ return {"tasks": ["A", "B"]}
596
+ return {"tasks": ["C"]}
597
+
598
+ # check 模拟:第一次 fail,第二次 pass
599
+ check_count = {"n": 0}
600
+
601
+ def check_fn(context, inputs):
602
+ check_count["n"] += 1
603
+ if check_count["n"] == 1:
604
+ return {"verdict": "fail"}
605
+ return {"verdict": "pass"}
606
+
607
+ registry.register_tool(
608
+ "test_producer",
609
+ Tool(name="test_producer", description="Produce tasks", function=producer_fn),
610
+ )
611
+ registry.register_tool(
612
+ "test_check",
613
+ Tool(name="test_check", description="Check results", function=check_fn),
614
+ )
615
+
616
+ flow = Flow(
617
+ flow_id="test",
618
+ name="reentry_test",
619
+ nodes=[
620
+ {
621
+ "id": "producer",
622
+ "type": "tool",
623
+ "config": {"tool_name": "test_producer", "inputs": {}},
624
+ },
625
+ {
626
+ "id": "loop",
627
+ "type": "iterator",
628
+ "config": {"items": "$o.producer.tasks"},
629
+ },
630
+ {
631
+ "id": "work",
632
+ "type": "tool",
633
+ "config": {
634
+ "tool_name": "test_echo",
635
+ "inputs": {"item": "$o.loop.current_item"},
636
+ "output_mode": "append",
637
+ },
638
+ },
639
+ {
640
+ "id": "check",
641
+ "type": "tool",
642
+ "config": {"tool_name": "test_check", "inputs": {}},
643
+ },
644
+ {
645
+ "id": "end",
646
+ "type": "python",
647
+ "config": {"code": "def main():\n return {'done': True}"},
648
+ },
649
+ ],
650
+ edges=[
651
+ {"source": "producer", "target": "loop"},
652
+ {"source": "loop", "target": "work"},
653
+ {"source": "work", "target": "loop"},
654
+ {"source": "loop", "target": "check", "condition": "$o.loop.done == true"},
655
+ {"source": "check", "target": "producer", "condition": "$o.check.verdict == fail"},
656
+ {"source": "check", "target": "end", "condition": "$o.check.verdict == pass"},
657
+ ],
658
+ cycle_limits={"loop": 20},
659
+ )
660
+ ctx = FlowContext()
661
+ run(collect_events(flow, ctx))
662
+
663
+ # work 应该累积了 3 次结果 (A, B from round 1 + C from round 2)
664
+ work_output = ctx.outputs["work"]
665
+ assert isinstance(work_output, list)
666
+ assert len(work_output) == 3
667
+ assert work_output[0] == {"echo": {"item": "A"}}
668
+ assert work_output[1] == {"echo": {"item": "B"}}
669
+ assert work_output[2] == {"echo": {"item": "C"}}
670
+
671
+ # loop 最终 state 已清理
672
+ assert ctx.get_variable("_iter_loop") is None
673
+
674
+ # check 最终为 pass
675
+ assert ctx.outputs["check"]["verdict"] == "pass"
676
+
677
+
678
+ class TestItemsInterpolation:
679
+ """$items 事件占位符"""
680
+
681
+ def test_items_returns_full_array(self):
682
+ handler = IteratorNodeHandler()
683
+ config = {
684
+ "events": {
685
+ "on_start": {
686
+ "name": "plan",
687
+ "value": {"tasks": "$items", "total": "$count"},
688
+ }
689
+ }
690
+ }
691
+ state = {"items": ["x", "y", "z"], "index": 0}
692
+ evt = handler._build_event(config, "on_start", state)
693
+ assert evt is not None
694
+ assert evt["value"]["tasks"] == ["x", "y", "z"]
695
+ assert evt["value"]["total"] == 3
428
696
 
429
697
 
430
698
  # ── Agent instructions 注入测试 ──
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