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.
- {agstack-1.23.0 → agstack-1.24.1}/PKG-INFO +1 -1
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/agent.py +3 -1
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/flow.py +20 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/iterator_node.py +8 -20
- {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-1.23.0 → agstack-1.24.1}/pyproject.toml +1 -1
- {agstack-1.23.0 → agstack-1.24.1}/tests/test_flow_iterator.py +302 -34
- {agstack-1.23.0 → agstack-1.24.1}/LICENSE +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/README.md +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/cache/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/cache/base.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/cache/memory.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/cache/redis.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/config/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/config/logger.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/config/manager.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/config/types.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/contexts.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/decorators.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/events.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/exceptions.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/exception.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/middleware.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/offline.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/fastapi/sse.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/infra/db/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/infra/es/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/infra/kg/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/infra/mq/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/client.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/context.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/event.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/factory.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/loader.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/agent_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/base.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/records.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/registry.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/state.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/tool.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/flow/trace.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/prompts.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/llm/token.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/messagebus/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/messagebus/base.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/messagebus/memory.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/messagebus/redis.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/schema.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/security/__init__.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/security/casbin.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/security/crypt.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack/status.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/SOURCES.txt +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/requires.txt +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/agstack.egg-info/top_level.txt +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/setup.cfg +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/tests/test_cache_memory.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/tests/test_cache_redis.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/tests/test_flow_io.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/tests/test_flow_switch_subflow.py +0 -0
- {agstack-1.23.0 → agstack-1.24.1}/tests/test_messagebus_memory.py +0 -0
- {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.
|
|
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"] =
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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>
|
|
@@ -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
|
|
85
|
-
self.handler._set_item_output("loop", state, {
|
|
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
|
|
96
|
-
self.handler._set_done_output("loop", state, {
|
|
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
|
|
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
|
|
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
|
-
#
|
|
184
|
-
|
|
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"
|
|
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["
|
|
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": {
|
|
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(
|
|
258
|
+
assert len(ctx.outputs["body"]) == 2
|
|
258
259
|
|
|
259
260
|
def test_error_tolerance(self):
|
|
260
|
-
"""
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|