agstack 1.22.1__tar.gz → 1.23.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.
- {agstack-1.22.1 → agstack-1.23.0}/PKG-INFO +1 -1
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/flow.py +43 -7
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/__init__.py +2 -0
- agstack-1.23.0/agstack/llm/flow/nodes/iterator_node.py +172 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-1.22.1 → agstack-1.23.0}/agstack.egg-info/SOURCES.txt +2 -0
- {agstack-1.22.1 → agstack-1.23.0}/pyproject.toml +1 -1
- agstack-1.23.0/tests/test_flow_iterator.py +501 -0
- {agstack-1.22.1 → agstack-1.23.0}/LICENSE +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/README.md +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/cache/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/cache/base.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/cache/memory.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/cache/redis.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/config/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/config/logger.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/config/manager.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/config/types.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/contexts.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/decorators.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/events.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/exceptions.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/fastapi/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/fastapi/exception.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/fastapi/middleware.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/fastapi/offline.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/fastapi/sse.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/infra/db/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/infra/es/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/infra/kg/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/infra/mq/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/client.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/agent.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/context.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/event.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/factory.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/loader.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/agent_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/base.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/records.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/registry.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/state.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/tool.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/flow/trace.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/prompts.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/llm/token.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/messagebus/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/messagebus/base.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/messagebus/memory.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/messagebus/redis.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/schema.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/security/__init__.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/security/casbin.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/security/crypt.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack/status.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack.egg-info/requires.txt +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/agstack.egg-info/top_level.txt +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/setup.cfg +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/tests/test_cache_memory.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/tests/test_cache_redis.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/tests/test_flow_io.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/tests/test_flow_switch_subflow.py +0 -0
- {agstack-1.22.1 → agstack-1.23.0}/tests/test_messagebus_memory.py +0 -0
- {agstack-1.22.1 → agstack-1.23.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.
|
|
3
|
+
Version: 1.23.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
|
-
|
|
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
|
-
|
|
504
|
+
_pqid: str = context.trace._qualify_id(current_node_id)
|
|
482
505
|
|
|
483
|
-
async def _exec_branch(branch_id: str,
|
|
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=
|
|
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,20 @@ class Flow:
|
|
|
600
623
|
label=config.get("label"),
|
|
601
624
|
)
|
|
602
625
|
|
|
603
|
-
|
|
604
|
-
|
|
626
|
+
try:
|
|
627
|
+
async for evt in self._execute_node_with_retry(node, context, current_node_id):
|
|
628
|
+
yield evt
|
|
629
|
+
except (NodeExecutionError, Exception) as e:
|
|
630
|
+
# 迭代体错误容忍:如果该节点有 edge 指向 iterator,路由回去而非中断 flow
|
|
631
|
+
iter_target = self._find_iterator_fallback(current_node_id)
|
|
632
|
+
if iter_target:
|
|
633
|
+
context.set_variable(f"_iter_{iter_target}_error", str(e))
|
|
634
|
+
context.set_output(current_node_id, {"error": str(e)})
|
|
635
|
+
context.trace.record_node_end(current_node_id, error=str(e))
|
|
636
|
+
context.set_variable("_prev_node_id", current_node_id)
|
|
637
|
+
current_node_id = iter_target
|
|
638
|
+
continue
|
|
639
|
+
raise
|
|
605
640
|
|
|
606
641
|
# 收集 agent 节点存放的 tool_calls 或通用 execution_records
|
|
607
642
|
tool_calls = context.get_variable("_last_node_tool_calls")
|
|
@@ -622,6 +657,7 @@ class Flow:
|
|
|
622
657
|
tool_calls=tool_calls if tool_calls else None,
|
|
623
658
|
messages=messages,
|
|
624
659
|
)
|
|
660
|
+
context.set_variable("_prev_node_id", current_node_id)
|
|
625
661
|
current_node_id = self._resolve_next_node(current_node_id, context)
|
|
626
662
|
|
|
627
663
|
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,172 @@
|
|
|
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
|
+
collect_to = config.get("collect_to", "results")
|
|
37
|
+
idx = state["index"]
|
|
38
|
+
context.set_output(
|
|
39
|
+
node_id,
|
|
40
|
+
{
|
|
41
|
+
"current_item": state["items"][idx],
|
|
42
|
+
"index": idx,
|
|
43
|
+
"count": len(state["items"]),
|
|
44
|
+
"done": False,
|
|
45
|
+
collect_to: list(state["collected"]),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def _set_done_output(self, node_id: str, state: dict, config: dict, context: "FlowContext") -> None:
|
|
50
|
+
collect_to = config.get("collect_to", "results")
|
|
51
|
+
context.set_output(
|
|
52
|
+
node_id,
|
|
53
|
+
{
|
|
54
|
+
"done": True,
|
|
55
|
+
"count": len(state["items"]),
|
|
56
|
+
collect_to: list(state["collected"]),
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _build_event(
|
|
61
|
+
self, config: dict, event_key: str, state: dict, *, error: str | None = None
|
|
62
|
+
) -> dict[str, Any] | None:
|
|
63
|
+
events_config = config.get("events")
|
|
64
|
+
if not events_config:
|
|
65
|
+
return None
|
|
66
|
+
evt_cfg = events_config.get(event_key)
|
|
67
|
+
if not evt_cfg:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
name = evt_cfg.get("name", event_key)
|
|
71
|
+
value_template = evt_cfg.get("value", {})
|
|
72
|
+
value = self._interpolate_event_value(value_template, state, error=error)
|
|
73
|
+
return event.custom(name=name, value=value)
|
|
74
|
+
|
|
75
|
+
def _interpolate_event_value(self, template: Any, state: dict, *, error: str | None = None) -> Any:
|
|
76
|
+
if isinstance(template, str):
|
|
77
|
+
if template == "$index":
|
|
78
|
+
return state["index"]
|
|
79
|
+
if template == "$item":
|
|
80
|
+
return state["items"][state["index"]] if state["index"] < len(state["items"]) else None
|
|
81
|
+
if template == "$count":
|
|
82
|
+
return len(state["items"])
|
|
83
|
+
if template == "$error":
|
|
84
|
+
return error
|
|
85
|
+
return template
|
|
86
|
+
if isinstance(template, dict):
|
|
87
|
+
return {k: self._interpolate_event_value(v, state, error=error) for k, v in template.items()}
|
|
88
|
+
if isinstance(template, list):
|
|
89
|
+
return [self._interpolate_event_value(v, state, error=error) for v in template]
|
|
90
|
+
return template
|
|
91
|
+
|
|
92
|
+
async def execute(self, node: dict, context: "FlowContext") -> Any:
|
|
93
|
+
config = node.get("config", {})
|
|
94
|
+
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}
|
|
97
|
+
|
|
98
|
+
async def stream(self, node: dict, context: "FlowContext", node_id: str) -> AsyncIterator[dict[str, Any]]:
|
|
99
|
+
config = node.get("config", {})
|
|
100
|
+
state_key = f"_iter_{node_id}"
|
|
101
|
+
step_name = self.get_step_name(node, node_id)
|
|
102
|
+
|
|
103
|
+
state = context.get_variable(state_key)
|
|
104
|
+
|
|
105
|
+
if state is None:
|
|
106
|
+
# ═══ 首次访问:初始化 ═══
|
|
107
|
+
items = self._resolve_items(config, context)
|
|
108
|
+
state = {"items": items, "index": 0, "collected": []}
|
|
109
|
+
context.set_variable(state_key, state)
|
|
110
|
+
|
|
111
|
+
sid = str(uuid4())
|
|
112
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
113
|
+
|
|
114
|
+
evt = self._build_event(config, "on_start", state)
|
|
115
|
+
if evt:
|
|
116
|
+
yield evt
|
|
117
|
+
|
|
118
|
+
if not items:
|
|
119
|
+
self._set_done_output(node_id, state, config, context)
|
|
120
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
self._set_item_output(node_id, state, config, context)
|
|
124
|
+
evt = self._build_event(config, "on_item_start", state)
|
|
125
|
+
if evt:
|
|
126
|
+
yield evt
|
|
127
|
+
|
|
128
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
129
|
+
|
|
130
|
+
else:
|
|
131
|
+
# ═══ Cycle-back:收集 + 推进 ═══
|
|
132
|
+
sid = str(uuid4())
|
|
133
|
+
yield event.step_started(step_name=step_name, step_id=sid)
|
|
134
|
+
|
|
135
|
+
prev_node_id = context.get_variable("_prev_node_id")
|
|
136
|
+
error = context.get_variable(f"_iter_{node_id}_error")
|
|
137
|
+
|
|
138
|
+
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
|
+
evt = self._build_event(config, "on_item_error", state, error=error)
|
|
146
|
+
if evt:
|
|
147
|
+
yield evt
|
|
148
|
+
context.set_variable(f"_iter_{node_id}_error", None)
|
|
149
|
+
else:
|
|
150
|
+
prev_output = context.outputs.get(prev_node_id) if prev_node_id else None
|
|
151
|
+
state["collected"].append(prev_output)
|
|
152
|
+
evt = self._build_event(config, "on_item_end", state)
|
|
153
|
+
if evt:
|
|
154
|
+
yield evt
|
|
155
|
+
|
|
156
|
+
state["index"] += 1
|
|
157
|
+
max_iter = config.get("max_iterations")
|
|
158
|
+
|
|
159
|
+
if state["index"] >= len(state["items"]) or (max_iter and state["index"] >= max_iter):
|
|
160
|
+
self._set_done_output(node_id, state, config, context)
|
|
161
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
self._set_item_output(node_id, state, config, context)
|
|
165
|
+
evt = self._build_event(config, "on_item_start", state)
|
|
166
|
+
if evt:
|
|
167
|
+
yield evt
|
|
168
|
+
|
|
169
|
+
yield event.step_finished(step_name=step_name, step_id=sid)
|
|
170
|
+
|
|
171
|
+
def get_step_name(self, node: dict, node_id: str) -> str:
|
|
172
|
+
return f"iterator:{node_id}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.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
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Iterator 节点及 Agent instructions 注入测试"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from agstack.llm.flow.context import FlowContext
|
|
8
|
+
from agstack.llm.flow.flow import Flow
|
|
9
|
+
from agstack.llm.flow.nodes.iterator_node import IteratorNodeHandler
|
|
10
|
+
from agstack.llm.flow.registry import registry
|
|
11
|
+
from agstack.llm.flow.tool import Tool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(coro):
|
|
15
|
+
return asyncio.get_event_loop().run_until_complete(coro)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def collect_events(flow: Flow, context: FlowContext) -> list[dict]:
|
|
19
|
+
events = []
|
|
20
|
+
async for evt in flow.stream(context):
|
|
21
|
+
events.append(evt)
|
|
22
|
+
return events
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── 辅助 mock tool ──
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _echo_tool_fn(context, inputs):
|
|
29
|
+
return {"echo": inputs}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _failing_tool_fn(context, inputs):
|
|
33
|
+
raise RuntimeError("tool_failed")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def setup_module():
|
|
37
|
+
"""注册测试用 mock tools"""
|
|
38
|
+
registry.register_tool(
|
|
39
|
+
"test_echo",
|
|
40
|
+
Tool(name="test_echo", description="Echo inputs", function=_echo_tool_fn),
|
|
41
|
+
)
|
|
42
|
+
registry.register_tool(
|
|
43
|
+
"test_fail",
|
|
44
|
+
Tool(name="test_fail", description="Always fails", function=_failing_tool_fn),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── IteratorNodeHandler 单元测试 ──
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestIteratorNodeHandler:
|
|
52
|
+
"""iterator 节点处理器单元测试"""
|
|
53
|
+
|
|
54
|
+
def setup_method(self):
|
|
55
|
+
self.handler = IteratorNodeHandler()
|
|
56
|
+
|
|
57
|
+
def test_resolve_items_from_variable(self):
|
|
58
|
+
ctx = FlowContext(variables={"tasks": [{"type": "a"}, {"type": "b"}]})
|
|
59
|
+
config = {"items": "$v.tasks"}
|
|
60
|
+
items = self.handler._resolve_items(config, ctx)
|
|
61
|
+
assert items == [{"type": "a"}, {"type": "b"}]
|
|
62
|
+
|
|
63
|
+
def test_resolve_items_from_output(self):
|
|
64
|
+
ctx = FlowContext()
|
|
65
|
+
ctx.set_output("planner", {"tasks": [1, 2, 3]})
|
|
66
|
+
config = {"items": "$o.planner.tasks"}
|
|
67
|
+
items = self.handler._resolve_items(config, ctx)
|
|
68
|
+
assert items == [1, 2, 3]
|
|
69
|
+
|
|
70
|
+
def test_resolve_items_none_returns_empty(self):
|
|
71
|
+
ctx = FlowContext()
|
|
72
|
+
config = {"items": "$v.missing"}
|
|
73
|
+
items = self.handler._resolve_items(config, ctx)
|
|
74
|
+
assert items == []
|
|
75
|
+
|
|
76
|
+
def test_resolve_items_non_list_wraps(self):
|
|
77
|
+
ctx = FlowContext(variables={"single": "value"})
|
|
78
|
+
config = {"items": "$v.single"}
|
|
79
|
+
items = self.handler._resolve_items(config, ctx)
|
|
80
|
+
assert items == ["value"]
|
|
81
|
+
|
|
82
|
+
def test_set_item_output(self):
|
|
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)
|
|
86
|
+
output = ctx.outputs["loop"]
|
|
87
|
+
assert output["current_item"] == "b"
|
|
88
|
+
assert output["index"] == 1
|
|
89
|
+
assert output["count"] == 3
|
|
90
|
+
assert output["done"] is False
|
|
91
|
+
assert output["results"] == ["prev"]
|
|
92
|
+
|
|
93
|
+
def test_set_done_output(self):
|
|
94
|
+
ctx = FlowContext()
|
|
95
|
+
state = {"items": ["a", "b"], "index": 2, "collected": ["r1", "r2"]}
|
|
96
|
+
self.handler._set_done_output("loop", state, {"collect_to": "results"}, ctx)
|
|
97
|
+
output = ctx.outputs["loop"]
|
|
98
|
+
assert output["done"] is True
|
|
99
|
+
assert output["count"] == 2
|
|
100
|
+
assert output["results"] == ["r1", "r2"]
|
|
101
|
+
|
|
102
|
+
def test_build_event_with_interpolation(self):
|
|
103
|
+
config = {
|
|
104
|
+
"events": {
|
|
105
|
+
"on_start": {
|
|
106
|
+
"name": "iteration_started",
|
|
107
|
+
"value": {"total": "$count", "first": "$item"},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
state = {"items": ["x", "y", "z"], "index": 0, "collected": []}
|
|
112
|
+
evt = self.handler._build_event(config, "on_start", state)
|
|
113
|
+
assert evt is not None
|
|
114
|
+
assert evt["type"] == "CUSTOM"
|
|
115
|
+
assert evt["name"] == "iteration_started"
|
|
116
|
+
assert evt["value"]["total"] == 3
|
|
117
|
+
assert evt["value"]["first"] == "x"
|
|
118
|
+
|
|
119
|
+
def test_build_event_missing_config_returns_none(self):
|
|
120
|
+
evt = self.handler._build_event({}, "on_start", {"items": [], "index": 0, "collected": []})
|
|
121
|
+
assert evt is None
|
|
122
|
+
|
|
123
|
+
def test_execute_returns_done(self):
|
|
124
|
+
"""execute() 返回 done 状态(用于非 edge-driven 场景)"""
|
|
125
|
+
ctx = FlowContext(variables={"items": [1, 2, 3]})
|
|
126
|
+
node = {"id": "loop", "type": "iterator", "config": {"items": "$v.items"}}
|
|
127
|
+
result = run(self.handler.execute(node, ctx))
|
|
128
|
+
assert result["done"] is True
|
|
129
|
+
assert result["count"] == 3
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Flow 集成测试:基本迭代 ──
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestIteratorFlowIntegration:
|
|
136
|
+
"""iterator 节点 + edge-driven flow 集成测试"""
|
|
137
|
+
|
|
138
|
+
def test_basic_iteration(self):
|
|
139
|
+
"""遍历 3 个 items,每轮调用 echo tool,收集结果"""
|
|
140
|
+
flow = Flow(
|
|
141
|
+
flow_id="test",
|
|
142
|
+
name="iter_test",
|
|
143
|
+
nodes=[
|
|
144
|
+
{
|
|
145
|
+
"id": "loop",
|
|
146
|
+
"type": "iterator",
|
|
147
|
+
"config": {
|
|
148
|
+
"items": "$v.tasks",
|
|
149
|
+
"collect_to": "results",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "do_work",
|
|
154
|
+
"type": "tool",
|
|
155
|
+
"config": {
|
|
156
|
+
"tool_name": "test_echo",
|
|
157
|
+
"inputs": {"query": "$o.loop.current_item"},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"id": "done_node",
|
|
162
|
+
"type": "python",
|
|
163
|
+
"config": {
|
|
164
|
+
"code": "def main():\n return {'status': 'done'}",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
edges=[
|
|
169
|
+
{"source": "loop", "target": "do_work"},
|
|
170
|
+
{"source": "do_work", "target": "loop"},
|
|
171
|
+
{"source": "loop", "target": "done_node", "condition": "$o.loop.done"},
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
ctx = FlowContext(variables={"tasks": ["task_a", "task_b", "task_c"]})
|
|
175
|
+
run(collect_events(flow, ctx))
|
|
176
|
+
|
|
177
|
+
# 验证迭代完成
|
|
178
|
+
loop_output = ctx.outputs["loop"]
|
|
179
|
+
assert loop_output["done"] is True
|
|
180
|
+
assert loop_output["count"] == 3
|
|
181
|
+
assert len(loop_output["results"]) == 3
|
|
182
|
+
|
|
183
|
+
# 验证每轮结果是 echo tool 的输出
|
|
184
|
+
for i, result in enumerate(loop_output["results"]):
|
|
185
|
+
assert result == {"echo": {"query": f"task_{chr(ord('a') + i)}"}}
|
|
186
|
+
|
|
187
|
+
def test_empty_items_goes_to_completion(self):
|
|
188
|
+
"""空数组直接走 completion edge"""
|
|
189
|
+
flow = Flow(
|
|
190
|
+
flow_id="test",
|
|
191
|
+
name="iter_empty",
|
|
192
|
+
nodes=[
|
|
193
|
+
{
|
|
194
|
+
"id": "loop",
|
|
195
|
+
"type": "iterator",
|
|
196
|
+
"config": {"items": "$v.tasks", "collect_to": "results"},
|
|
197
|
+
},
|
|
198
|
+
{"id": "body", "type": "tool", "config": {"tool_name": "test_echo", "inputs": {}}},
|
|
199
|
+
{
|
|
200
|
+
"id": "end",
|
|
201
|
+
"type": "python",
|
|
202
|
+
"config": {"code": "def main():\n return {'status': 'done'}"},
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
edges=[
|
|
206
|
+
{"source": "loop", "target": "body"},
|
|
207
|
+
{"source": "body", "target": "loop"},
|
|
208
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
209
|
+
],
|
|
210
|
+
)
|
|
211
|
+
ctx = FlowContext(variables={"tasks": []})
|
|
212
|
+
run(collect_events(flow, ctx))
|
|
213
|
+
|
|
214
|
+
loop_output = ctx.outputs["loop"]
|
|
215
|
+
assert loop_output["done"] is True
|
|
216
|
+
assert loop_output["results"] == []
|
|
217
|
+
# end 节点应该被执行
|
|
218
|
+
assert "end" in ctx.outputs
|
|
219
|
+
|
|
220
|
+
def test_max_iterations_truncates(self):
|
|
221
|
+
"""max_iterations 截断循环"""
|
|
222
|
+
flow = Flow(
|
|
223
|
+
flow_id="test",
|
|
224
|
+
name="iter_max",
|
|
225
|
+
nodes=[
|
|
226
|
+
{
|
|
227
|
+
"id": "loop",
|
|
228
|
+
"type": "iterator",
|
|
229
|
+
"config": {
|
|
230
|
+
"items": "$v.tasks",
|
|
231
|
+
"collect_to": "results",
|
|
232
|
+
"max_iterations": 2,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"id": "body",
|
|
237
|
+
"type": "tool",
|
|
238
|
+
"config": {"tool_name": "test_echo", "inputs": {"x": "$o.loop.current_item"}},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"id": "end",
|
|
242
|
+
"type": "python",
|
|
243
|
+
"config": {"code": "def main():\n return {'status': 'truncated'}"},
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
edges=[
|
|
247
|
+
{"source": "loop", "target": "body"},
|
|
248
|
+
{"source": "body", "target": "loop"},
|
|
249
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
250
|
+
],
|
|
251
|
+
)
|
|
252
|
+
ctx = FlowContext(variables={"tasks": ["a", "b", "c", "d", "e"]})
|
|
253
|
+
run(collect_events(flow, ctx))
|
|
254
|
+
|
|
255
|
+
loop_output = ctx.outputs["loop"]
|
|
256
|
+
assert loop_output["done"] is True
|
|
257
|
+
assert len(loop_output["results"]) == 2
|
|
258
|
+
|
|
259
|
+
def test_error_tolerance(self):
|
|
260
|
+
"""单项失败不中断循环,记录 error 后继续"""
|
|
261
|
+
flow = Flow(
|
|
262
|
+
flow_id="test",
|
|
263
|
+
name="iter_error",
|
|
264
|
+
nodes=[
|
|
265
|
+
{
|
|
266
|
+
"id": "loop",
|
|
267
|
+
"type": "iterator",
|
|
268
|
+
"config": {"items": "$v.tasks", "collect_to": "results"},
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"id": "body",
|
|
272
|
+
"type": "tool",
|
|
273
|
+
"config": {"tool_name": "test_fail", "inputs": {}},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"id": "end",
|
|
277
|
+
"type": "python",
|
|
278
|
+
"config": {"code": "def main():\n return {'status': 'done'}"},
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
edges=[
|
|
282
|
+
{"source": "loop", "target": "body"},
|
|
283
|
+
{"source": "body", "target": "loop"},
|
|
284
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
285
|
+
],
|
|
286
|
+
)
|
|
287
|
+
ctx = FlowContext(variables={"tasks": ["a", "b"]})
|
|
288
|
+
run(collect_events(flow, ctx))
|
|
289
|
+
|
|
290
|
+
loop_output = ctx.outputs["loop"]
|
|
291
|
+
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
|
|
296
|
+
|
|
297
|
+
def test_custom_events_emitted(self):
|
|
298
|
+
"""验证 CUSTOM 事件发射"""
|
|
299
|
+
flow = Flow(
|
|
300
|
+
flow_id="test",
|
|
301
|
+
name="iter_events",
|
|
302
|
+
nodes=[
|
|
303
|
+
{
|
|
304
|
+
"id": "loop",
|
|
305
|
+
"type": "iterator",
|
|
306
|
+
"config": {
|
|
307
|
+
"items": "$v.tasks",
|
|
308
|
+
"collect_to": "results",
|
|
309
|
+
"events": {
|
|
310
|
+
"on_start": {"name": "loop_started", "value": {"total": "$count"}},
|
|
311
|
+
"on_item_start": {"name": "item_begin", "value": {"idx": "$index"}},
|
|
312
|
+
"on_item_end": {"name": "item_done", "value": {"idx": "$index"}},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"id": "body",
|
|
318
|
+
"type": "tool",
|
|
319
|
+
"config": {"tool_name": "test_echo", "inputs": {}},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
"id": "end",
|
|
323
|
+
"type": "python",
|
|
324
|
+
"config": {"code": "def main():\n return {'status': 'ok'}"},
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
edges=[
|
|
328
|
+
{"source": "loop", "target": "body"},
|
|
329
|
+
{"source": "body", "target": "loop"},
|
|
330
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
ctx = FlowContext(variables={"tasks": ["x", "y"]})
|
|
334
|
+
events = run(collect_events(flow, ctx))
|
|
335
|
+
|
|
336
|
+
custom_events = [e for e in events if e.get("type") == "CUSTOM"]
|
|
337
|
+
custom_names = [e.get("name") for e in custom_events]
|
|
338
|
+
|
|
339
|
+
assert "loop_started" in custom_names
|
|
340
|
+
assert "item_begin" in custom_names
|
|
341
|
+
assert "item_done" in custom_names
|
|
342
|
+
|
|
343
|
+
def test_with_switch_routing(self):
|
|
344
|
+
"""与 switch 节点组合实现按类型分发"""
|
|
345
|
+
|
|
346
|
+
# 注册两个不同的 tool
|
|
347
|
+
def tool_upper(context, inputs):
|
|
348
|
+
return {"result": inputs.get("text", "").upper()}
|
|
349
|
+
|
|
350
|
+
def tool_lower(context, inputs):
|
|
351
|
+
return {"result": inputs.get("text", "").lower()}
|
|
352
|
+
|
|
353
|
+
registry.register_tool(
|
|
354
|
+
"test_upper",
|
|
355
|
+
Tool(name="test_upper", description="Uppercase", function=tool_upper),
|
|
356
|
+
)
|
|
357
|
+
registry.register_tool(
|
|
358
|
+
"test_lower",
|
|
359
|
+
Tool(name="test_lower", description="Lowercase", function=tool_lower),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
flow = Flow(
|
|
363
|
+
flow_id="test",
|
|
364
|
+
name="iter_switch",
|
|
365
|
+
nodes=[
|
|
366
|
+
{
|
|
367
|
+
"id": "loop",
|
|
368
|
+
"type": "iterator",
|
|
369
|
+
"config": {"items": "$v.tasks", "collect_to": "results"},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
"id": "route",
|
|
373
|
+
"type": "switch",
|
|
374
|
+
"config": {
|
|
375
|
+
"variable": "$o.loop.current_item.action",
|
|
376
|
+
"cases": {"upper": "upper", "lower": "lower"},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
"id": "do_upper",
|
|
381
|
+
"type": "tool",
|
|
382
|
+
"config": {
|
|
383
|
+
"tool_name": "test_upper",
|
|
384
|
+
"inputs": {"text": "$o.loop.current_item.text"},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"id": "do_lower",
|
|
389
|
+
"type": "tool",
|
|
390
|
+
"config": {
|
|
391
|
+
"tool_name": "test_lower",
|
|
392
|
+
"inputs": {"text": "$o.loop.current_item.text"},
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
"id": "end",
|
|
397
|
+
"type": "python",
|
|
398
|
+
"config": {"code": "def main():\n return {'status': 'done'}"},
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
edges=[
|
|
402
|
+
{"source": "loop", "target": "route"},
|
|
403
|
+
{"source": "route", "target": "do_upper", "condition": "$o.route.choice == upper"},
|
|
404
|
+
{"source": "route", "target": "do_lower", "condition": "$o.route.choice == lower"},
|
|
405
|
+
{"source": "do_upper", "target": "loop"},
|
|
406
|
+
{"source": "do_lower", "target": "loop"},
|
|
407
|
+
{"source": "loop", "target": "end", "condition": "$o.loop.done"},
|
|
408
|
+
],
|
|
409
|
+
)
|
|
410
|
+
ctx = FlowContext(
|
|
411
|
+
variables={
|
|
412
|
+
"tasks": [
|
|
413
|
+
{"action": "upper", "text": "hello"},
|
|
414
|
+
{"action": "lower", "text": "WORLD"},
|
|
415
|
+
{"action": "upper", "text": "foo"},
|
|
416
|
+
]
|
|
417
|
+
}
|
|
418
|
+
)
|
|
419
|
+
run(collect_events(flow, ctx))
|
|
420
|
+
|
|
421
|
+
loop_output = ctx.outputs["loop"]
|
|
422
|
+
assert loop_output["done"] is True
|
|
423
|
+
assert loop_output["results"] == [
|
|
424
|
+
{"result": "HELLO"},
|
|
425
|
+
{"result": "world"},
|
|
426
|
+
{"result": "FOO"},
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ── Agent instructions 注入测试 ──
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class TestAgentInstructionsInjection:
|
|
434
|
+
"""验证 Agent 节点 config.instructions 传递"""
|
|
435
|
+
|
|
436
|
+
def test_instructions_passthrough_via_kwargs(self):
|
|
437
|
+
"""instructions 字段通过 _create_agent kwargs 机制传递到 Agent"""
|
|
438
|
+
from agstack.llm.flow.agent import Agent
|
|
439
|
+
from agstack.llm.flow.nodes.agent_node import AgentNodeHandler
|
|
440
|
+
|
|
441
|
+
# 注册一个测试 agent
|
|
442
|
+
class TestAgent(Agent):
|
|
443
|
+
def __init__(self, **kwargs):
|
|
444
|
+
super().__init__(name="test_agent", **kwargs)
|
|
445
|
+
|
|
446
|
+
registry.register_agent("test_agent", TestAgent)
|
|
447
|
+
|
|
448
|
+
handler = AgentNodeHandler()
|
|
449
|
+
ctx = FlowContext(variables={"custom_prompt": "You are a custom assistant."})
|
|
450
|
+
config = {
|
|
451
|
+
"agent_name": "test_agent",
|
|
452
|
+
"instructions": "$v.custom_prompt",
|
|
453
|
+
"inputs": {"input": "hello"},
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
agent = handler._create_agent(config, ctx)
|
|
457
|
+
assert agent.instructions == "You are a custom assistant."
|
|
458
|
+
|
|
459
|
+
def test_instructions_literal_string(self):
|
|
460
|
+
"""instructions 字段为字面量字符串"""
|
|
461
|
+
from agstack.llm.flow.agent import Agent
|
|
462
|
+
from agstack.llm.flow.nodes.agent_node import AgentNodeHandler
|
|
463
|
+
|
|
464
|
+
class LiteralAgent(Agent):
|
|
465
|
+
def __init__(self, **kwargs):
|
|
466
|
+
super().__init__(name="literal_agent", **kwargs)
|
|
467
|
+
|
|
468
|
+
registry.register_agent("literal_agent", LiteralAgent)
|
|
469
|
+
|
|
470
|
+
handler = AgentNodeHandler()
|
|
471
|
+
ctx = FlowContext()
|
|
472
|
+
config = {
|
|
473
|
+
"agent_name": "literal_agent",
|
|
474
|
+
"instructions": "Be concise and direct.",
|
|
475
|
+
"inputs": {},
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
agent = handler._create_agent(config, ctx)
|
|
479
|
+
assert agent.instructions == "Be concise and direct."
|
|
480
|
+
|
|
481
|
+
def test_no_instructions_uses_default(self):
|
|
482
|
+
"""不提供 instructions 时使用 Agent 默认值"""
|
|
483
|
+
from agstack.llm.flow.agent import Agent
|
|
484
|
+
from agstack.llm.flow.nodes.agent_node import AgentNodeHandler
|
|
485
|
+
|
|
486
|
+
class DefaultAgent(Agent):
|
|
487
|
+
def __init__(self, **kwargs):
|
|
488
|
+
kwargs.setdefault("instructions", "I am the default.")
|
|
489
|
+
super().__init__(name="default_agent", **kwargs)
|
|
490
|
+
|
|
491
|
+
registry.register_agent("default_agent", DefaultAgent)
|
|
492
|
+
|
|
493
|
+
handler = AgentNodeHandler()
|
|
494
|
+
ctx = FlowContext()
|
|
495
|
+
config = {
|
|
496
|
+
"agent_name": "default_agent",
|
|
497
|
+
"inputs": {},
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
agent = handler._create_agent(config, ctx)
|
|
501
|
+
assert agent.instructions == "I am the default."
|
|
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
|